@opensalt/ob3-definer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.vscode/extensions.json +3 -0
  2. package/LICENSE +21 -0
  3. package/README.md +86 -0
  4. package/dist/favicon.svg +7 -0
  5. package/dist/index.html +20 -0
  6. package/dist/ob3-definer.css +1 -0
  7. package/dist/ob3-definer.js +32 -0
  8. package/formkit.config.js +27 -0
  9. package/index.html +19 -0
  10. package/jsconfig.json +8 -0
  11. package/package.json +35 -0
  12. package/public/favicon.svg +7 -0
  13. package/src/App.vue +19 -0
  14. package/src/assets/base.css +86 -0
  15. package/src/assets/main.css +24 -0
  16. package/src/assets/style.scss +33 -0
  17. package/src/components/AchievementCriteria.vue +78 -0
  18. package/src/components/AchievementDefiner.vue +212 -0
  19. package/src/components/AchievementImage.vue +116 -0
  20. package/src/components/AchievementType.vue +111 -0
  21. package/src/components/AdditionalTab.vue +32 -0
  22. package/src/components/AddressComponent.vue +118 -0
  23. package/src/components/AlignmentComponent.vue +141 -0
  24. package/src/components/AlignmentsComponent.vue +13 -0
  25. package/src/components/AlignmentsTab.vue +18 -0
  26. package/src/components/BasicTab.vue +55 -0
  27. package/src/components/CreatorProfile.vue +166 -0
  28. package/src/components/CriterionLevels.vue +97 -0
  29. package/src/components/DetailTab.vue +72 -0
  30. package/src/components/IndividualName.vue +63 -0
  31. package/src/components/MarkdownRenderer.vue +20 -0
  32. package/src/components/OtherIdentifiers.vue +116 -0
  33. package/src/components/RelatedList.vue +89 -0
  34. package/src/components/ResultDescription.vue +120 -0
  35. package/src/components/ResultType.vue +94 -0
  36. package/src/components/TagList.vue +121 -0
  37. package/src/components/ValueList.vue +144 -0
  38. package/src/inputs/innerLabelTextInput.js +62 -0
  39. package/src/inputs/innerLabelTextareaInput.js +57 -0
  40. package/src/inputs/selectInputGroup.js +76 -0
  41. package/src/main.js +50 -0
  42. package/src/stores/credential.js +292 -0
  43. package/test-index.html +17 -0
  44. package/trial-key +3 -0
  45. package/vite.config.js +39 -0
@@ -0,0 +1,27 @@
1
+ import { generateClasses } from '@formkit/themes'
2
+
3
+ const config = {
4
+ config: {
5
+ classes: generateClasses({
6
+ global: { // classes
7
+ outer: '$reset mb-4',
8
+ input: 'form-control',
9
+ label: 'form-label',
10
+ help: 'form-text',
11
+ message: 'invalid-feedback',
12
+ },
13
+ form: {
14
+ form: "mt-1 mx-auto p-3 border rounded"
15
+ },
16
+ range: {
17
+ input: '$reset form-range',
18
+ },
19
+ submit: {
20
+ outer: '$reset mt-3',
21
+ input: '$reset btn btn-primary'
22
+ }
23
+ })
24
+ }
25
+ }
26
+
27
+ export default config
package/index.html ADDED
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <link rel="icon" href="/favicon.svg">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>OB3 Achievement Definer</title>
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
9
+ <script type="module" src="/src/main.js"></script>
10
+ </head>
11
+ <body>
12
+ <div id="ob3-definer"></div>
13
+ <script>
14
+ document.addEventListener('DOMContentLoaded', () => {
15
+ window.dispatchEvent(new CustomEvent('ob3-open'));
16
+ })
17
+ </script>
18
+ </body>
19
+ </html>
package/jsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {
4
+ "@/*": ["./src/*"]
5
+ }
6
+ },
7
+ "exclude": ["node_modules", "dist"]
8
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@opensalt/ob3-definer",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/opensalt/OB3DefinitionWidget.git"
8
+ },
9
+ "type": "module",
10
+ "description": "A widget to create an Open Badge achievement definition.",
11
+ "main": "dist/ob3-definer.js",
12
+ "style": "dist/ob3-definer.css",
13
+ "unpkg": "dist/ob3-definer.js",
14
+ "scripts": {
15
+ "dev": "vite",
16
+ "build": "vite build",
17
+ "preview": "vite preview"
18
+ },
19
+ "dependencies": {
20
+ "@formkit/auto-animate": "^0.8.2",
21
+ "@formkit/themes": "^1.6.5",
22
+ "@formkit/utils": "^1.6.5",
23
+ "@formkit/vue": "^1.6.5",
24
+ "@popperjs/core": "^2.11.8",
25
+ "bootstrap": "^5.3.3",
26
+ "markdown-it": "^14.1.0",
27
+ "uuid": "^10.0.0",
28
+ "vue": "^3.4.29"
29
+ },
30
+ "devDependencies": {
31
+ "@vitejs/plugin-vue": "^5.0.5",
32
+ "sass": "1.77.6",
33
+ "vite": "^5.3.1"
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g id="Layer_1">
3
+ <g id="favicon" stroke-width="0"/>
4
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M10.586 5.062l7.876 7.878-9.063 9.156-3.39-3.387 9.063-9.157-2.977-2.977-5.502 4.086 3.993 4.007-1.414 1.415-4.007-4.008-1.472 1.374 5.502 4.086 2.836-2.838 3.888 3.89z" fill="#58B4AE"/>
5
+ <path d="M3.324 18.981l3.389 3.389 9.787-9.924-3.39-3.388-9.786 9.923zM7.51 22.27l-2.977-2.977-.732.866 2.836 2.838 3.112-2.256-.473-.47-2.766 2.999.232.125.284-.125 1.382-.811z" fill="#95E1D3"/>
6
+ </g>
7
+ </svg>
package/src/App.vue ADDED
@@ -0,0 +1,19 @@
1
+ <script setup>
2
+ import AchievementDefiner from "@/components/AchievementDefiner.vue";
3
+
4
+ const props = defineProps({
5
+ achievement: {
6
+ type: String,
7
+ default: ""
8
+ }
9
+ });
10
+ </script>
11
+
12
+ <template>
13
+ <div class="container">
14
+ <AchievementDefiner :achievement="achievement"/>
15
+ </div>
16
+ </template>
17
+
18
+ <style scoped>
19
+ </style>
@@ -0,0 +1,86 @@
1
+ /* color palette from <https://github.com/vuejs/theme> */
2
+ :root {
3
+ --vt-c-white: #ffffff;
4
+ --vt-c-white-soft: #f8f8f8;
5
+ --vt-c-white-mute: #f2f2f2;
6
+
7
+ --vt-c-black: #181818;
8
+ --vt-c-black-soft: #222222;
9
+ --vt-c-black-mute: #282828;
10
+
11
+ --vt-c-indigo: #2c3e50;
12
+
13
+ --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
14
+ --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
15
+ --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
16
+ --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
17
+
18
+ --vt-c-text-light-1: var(--vt-c-indigo);
19
+ --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
20
+ --vt-c-text-dark-1: var(--vt-c-white);
21
+ --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
22
+ }
23
+
24
+ /* semantic color variables for this project */
25
+ :root {
26
+ --color-background: var(--vt-c-white);
27
+ --color-background-soft: var(--vt-c-white-soft);
28
+ --color-background-mute: var(--vt-c-white-mute);
29
+
30
+ --color-border: var(--vt-c-divider-light-2);
31
+ --color-border-hover: var(--vt-c-divider-light-1);
32
+
33
+ --color-heading: var(--vt-c-text-light-1);
34
+ --color-text: var(--vt-c-text-light-1);
35
+
36
+ --section-gap: 160px;
37
+ }
38
+
39
+ @media (prefers-color-scheme: dark) {
40
+ :root {
41
+ --color-background: var(--vt-c-black);
42
+ --color-background-soft: var(--vt-c-black-soft);
43
+ --color-background-mute: var(--vt-c-black-mute);
44
+
45
+ --color-border: var(--vt-c-divider-dark-2);
46
+ --color-border-hover: var(--vt-c-divider-dark-1);
47
+
48
+ --color-heading: var(--vt-c-text-dark-1);
49
+ --color-text: var(--vt-c-text-dark-2);
50
+ }
51
+ }
52
+
53
+ *,
54
+ *::before,
55
+ *::after {
56
+ box-sizing: border-box;
57
+ margin: 0;
58
+ font-weight: normal;
59
+ }
60
+
61
+ body {
62
+ min-height: 100vh;
63
+ color: var(--color-text);
64
+ background: var(--color-background);
65
+ transition:
66
+ color 0.5s,
67
+ background-color 0.5s;
68
+ line-height: 1.6;
69
+ font-family:
70
+ Inter,
71
+ -apple-system,
72
+ BlinkMacSystemFont,
73
+ 'Segoe UI',
74
+ Roboto,
75
+ Oxygen,
76
+ Ubuntu,
77
+ Cantarell,
78
+ 'Fira Sans',
79
+ 'Droid Sans',
80
+ 'Helvetica Neue',
81
+ sans-serif;
82
+ font-size: 15px;
83
+ text-rendering: optimizeLegibility;
84
+ -webkit-font-smoothing: antialiased;
85
+ -moz-osx-font-smoothing: grayscale;
86
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ @import './base.css';
3
+ */
4
+
5
+ #app {
6
+ max-width: 1280px;
7
+ margin: 0 auto;
8
+ padding: 2rem;
9
+ font-weight: normal;
10
+ }
11
+
12
+ @media (min-width: 1024px) {
13
+ body {
14
+ display: flex;
15
+ width: 1024px;
16
+ /*place-items: center;*/
17
+ }
18
+
19
+ #app {
20
+ display: grid;
21
+ grid-template-columns: 1fr 1fr;
22
+ padding: 0 2rem;
23
+ }
24
+ }
@@ -0,0 +1,33 @@
1
+ @import 'bootstrap/scss/bootstrap';
2
+
3
+ [data-complete="true"]:not([data-empty="true"]) .form-control {
4
+ //@extend .form-control, .is-valid;
5
+ /*
6
+ border-color: var(--bs-form-valid-border-color);
7
+ padding-right: calc(1.5em + 0.75rem);
8
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
9
+ background-repeat: no-repeat;
10
+ background-position: right calc(0.375em + 0.1875rem) center;
11
+ background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
12
+ */
13
+ }
14
+
15
+ /*
16
+ [data-invalid="true"] .form-control {
17
+ //@extend .form-control, .is-invalid;
18
+ border-color: var(--bs-form-invalid-border-color);
19
+ padding-right: calc(1.5em + 0.75rem);
20
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
21
+ background-repeat: no-repeat;
22
+ background-position: right calc(0.375em + 0.1875rem) center;
23
+ background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
24
+ }
25
+
26
+ [data-invalid="true"] .invalid-feedback {
27
+ display: inline;
28
+ width: 100%;
29
+ margin-top: 0.25rem;
30
+ font-size: 0.875em;
31
+ color: var(--bs-form-invalid-color);
32
+ }
33
+ */
@@ -0,0 +1,78 @@
1
+ <script setup>
2
+ import {onMounted, ref, watch} from "vue";
3
+ import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
4
+
5
+ const model = defineModel({ default: {
6
+ }});
7
+ const preview = ref('');
8
+
9
+ watch(model, () => {
10
+ preview.value = model.value.narrative;
11
+ })
12
+
13
+ onMounted(() => {
14
+ preview.value = model.value.narrative ? model.value.narrative : '';
15
+ })
16
+
17
+ const criteriaTab = ref("edit");
18
+
19
+ function selectTab(tab) {
20
+ criteriaTab.value = tab;
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <div class="formkit-wrapper required">
26
+ <label class="form-label">Earning Criteria</label>
27
+ </div>
28
+
29
+ <ul class="nav nav-tabs" role="tablist">
30
+ <li class="nav-item" role="presentation">
31
+ <button class="nav-link" :class='{"active": criteriaTab === "edit"}' type="button" id="edit-tab" aria-controls="criteria-edit"
32
+ role="tab" :aria-selected="criteriaTab === 'edit'" @click="selectTab('edit')">Edit
33
+ </button>
34
+ </li>
35
+ <li class="nav-item" role="presentation">
36
+ <button class="nav-link" :class='{"active": criteriaTab === "preview"}' type="button" id="preview-tab"
37
+ aria-controls="criteria-preview" role="tab" :aria-selected="criteriaTab === 'preview'"
38
+ @click="selectTab('preview')">Preview
39
+ </button>
40
+ </li>
41
+ </ul>
42
+
43
+ <div class="tab-content" id="criteria-tab">
44
+ <div class="tab-pane fade" :class="{'show': (criteriaTab === 'edit'), 'active': (criteriaTab === 'edit')}"
45
+ id="criteria-edit" role="tabpanel" aria-labelledby="edit-tab" v-show="criteriaTab === 'edit'">
46
+ <FormKit type="group" name="criteria" v-model="model">
47
+ <FormKit
48
+ type="textarea"
49
+ label="Narrative"
50
+ name="narrative"
51
+ rows="5"
52
+ validation="require_one:url"
53
+ help="A narrative of what is needed to earn the credential. Markdown is allowed."
54
+ />
55
+
56
+ <FormKit
57
+ type="text"
58
+ label="Criteria URL"
59
+ name="url"
60
+ validation="require_one:narrative|url"
61
+ help="The URL of a webpage that describes in a human-readable format the criteria for the credential."
62
+ />
63
+ </FormKit>
64
+ </div>
65
+
66
+ <div class="tab-pane fade" :class="{'show': (criteriaTab === 'preview'), 'active': (criteriaTab === 'preview')}"
67
+ id="criteria-preview" role="tabpanel" aria-labelledby="preview-tab" v-show="criteriaTab === 'preview'">
68
+ <label class="mt-3 mb-2">Narrative Preview</label>
69
+ <MarkdownRenderer :source="preview" class="border rounded p-3"/>
70
+ </div>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped>
75
+ #criteria-preview {
76
+ min-height: 302px;
77
+ }
78
+ </style>
@@ -0,0 +1,212 @@
1
+ <script setup>
2
+ import {onBeforeMount, reactive, ref, watch} from "vue";
3
+ import BasicTab from "@/components/BasicTab.vue";
4
+ import DetailTab from "@/components/DetailTab.vue";
5
+ import AlignmentsTab from "@/components/AlignmentsTab.vue";
6
+ import AdditionalTab from "@/components/AdditionalTab.vue";
7
+
8
+ const props = defineProps({
9
+ achievement: {
10
+ type: String,
11
+ default: ""
12
+ }
13
+ });
14
+ const emit = defineEmits(['saveDefinition']);
15
+
16
+ const tab = ref("basic");
17
+ const submitted = ref(false);
18
+ const form = ref(null);
19
+
20
+ const formData = reactive({
21
+ type: ['Achievement'],
22
+ basic: {},
23
+ detail: {},
24
+ alignments: {},
25
+ additional: {},
26
+ });
27
+
28
+ const achievementData = reactive({});
29
+
30
+ onBeforeMount(() => {
31
+ try {
32
+ const achievement = JSON.parse(props.achievement);
33
+
34
+ formData.basic = {
35
+ name: achievement.name || '',
36
+ achievementType: achievement.achievementType || null,
37
+ image: achievement.image || {},
38
+ description: achievement.description || '',
39
+ criteria: achievement.criteria || {},
40
+ };
41
+ formData.detail = {
42
+ humanCode: achievement.humanCode || null,
43
+ inLanguage: achievement.inLanguage || null,
44
+ version: achievement.version || null,
45
+ creditsAvailable: achievement.creditsAvailable || null,
46
+ specialization: achievement.specialization || null,
47
+ fieldOfStudy: achievement.fieldOfStudy || null,
48
+ };
49
+ formData.alignments = {
50
+ resultDescription: achievement.resultDescription || [],
51
+ alignment: achievement.alignment || [],
52
+ };
53
+ formData.additional = {
54
+ id: achievement.id || null,
55
+ tag: achievement.tag || [],
56
+ related: achievement.related || [],
57
+ otherIdentifier: achievement.otherIdentifier || [],
58
+ creator: achievement.creator || {},
59
+ };
60
+ /*
61
+ achievementData.basic = {...achievement.basic };
62
+ achievementData.detail = {...achievement.detail };
63
+ achievementData.alignments = {...achievement.alignments };
64
+ achievementData.additional = {...achievement.additional };
65
+ */
66
+ } catch (e) {
67
+ // No valid OB3 Achievement definition was passed
68
+ }
69
+ });
70
+
71
+ watch(formData, (value) => {
72
+ Object.assign(achievementData, value.basic);
73
+ Object.assign(achievementData, value.detail);
74
+ Object.assign(achievementData, value.alignments);
75
+ Object.assign(achievementData, value.additional);
76
+ });
77
+
78
+ function selectTab(selected) {
79
+ tab.value = selected;
80
+ }
81
+
82
+ function removeEmpty(obj) {
83
+ /*
84
+ return Object.fromEntries(
85
+ Object.entries(obj)
86
+ .filter(([_, v]) => v != null && v !== '' && (!Array.isArray(v) || v.length > 0))
87
+ .map(([k, v]) => [k, (v === Object(v) && !Array.isArray(v)) ? removeEmpty(v) : v])
88
+ );
89
+ */
90
+ return JSON.parse(JSON.stringify(obj, (key, value) => {
91
+ return ((value === null || value === '' || (Array.isArray(value) && value.length === 0)) ? undefined : value);
92
+ }));
93
+ }
94
+
95
+ function save(formData) {
96
+ const cleaned = removeEmpty(formData);
97
+ //emit('saveDefinition', cleaned);
98
+ const formEl = document.getElementById(form.value.node.props.id);
99
+ formEl.dispatchEvent(new CustomEvent('saveDefinition', { bubbles: true, detail: JSON.stringify(cleaned) }));
100
+ //console.log('Submitting', cleaned);
101
+ }
102
+
103
+ function sendChange(formData) {
104
+ try {
105
+ const formEl = document.getElementById(form.value.node.props.id);
106
+ const valid = form.value.node.context.state.valid;
107
+ const cleaned = removeEmpty(form.value.node.value);
108
+ formEl.dispatchEvent(new CustomEvent('changed', {bubbles: true, detail: JSON.stringify(cleaned)}));
109
+ if (valid) {
110
+ formEl.dispatchEvent(new CustomEvent('updated', {bubbles: true, detail: JSON.stringify(cleaned)}));
111
+ }
112
+ } catch (e) {}
113
+ }
114
+
115
+ function showErrors(node) {
116
+ submitted.value = true;
117
+ }
118
+ </script>
119
+
120
+ <template>
121
+ <div>
122
+ <ul class="nav nav-tabs" role="tablist">
123
+ <li class="nav-item" role="presentation">
124
+ <button class="nav-link" :class='{"active": (tab === "basic")}' type="button" id="basic-tab" role="tab" aria-controls="tab-basic" :aria-selected="(tab === 'basic') ? 'true' : 'false'" @click="selectTab('basic')">Primary Details</button>
125
+ </li>
126
+ <li class="nav-item" role="presentation">
127
+ <button class="nav-link" :class='{"active": (tab === "detail")}' type="button" id="detail-tab" role="tab" aria-controls="tab-detail" :aria-selected="(tab === 'detail') ? 'true' : 'false'" @click="selectTab('detail')">Additional Details</button>
128
+ </li>
129
+ <li class="nav-item" role="presentation">
130
+ <button class="nav-link" :class='{"active": (tab === "alignments")}' type="button" id="detail-tab" role="tab" aria-controls="tab-alignments" :aria-selected="(tab === 'alignments') ? 'true' : 'false'" @click="selectTab('alignments')">Alignments</button>
131
+ </li>
132
+ <li class="nav-item" role="presentation">
133
+ <button class="nav-link" :class='{"active": (tab === "additional")}' type="button" id="additional-tab" role="tab" aria-controls="tab-additional" :aria-selected="(tab === 'additional') ? 'true' : 'false'" @click="selectTab('additional')">Additional Information</button>
134
+ </li>
135
+ </ul>
136
+
137
+ <div class="tab-content mt-3" id="tab-content">
138
+ <FormKit
139
+ type="form"
140
+ :actions="false"
141
+ ref="form"
142
+ @submit="save"
143
+ @submit-invalid="showErrors"
144
+ @change="sendChange"
145
+ #default="{ state: { valid } }"
146
+ validation-visibility="live"
147
+ >
148
+ <div class="alert alert-warning" role="alert" v-if="!valid && submitted">
149
+ There are some errors in the form submission. Please correct the errors and then resubmit the form.
150
+ </div>
151
+
152
+ <FormKit
153
+ type="hidden"
154
+ name="@context"
155
+ :value="[ 'https://www.w3.org/2018/credentials/v2', 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json' ]"
156
+ />
157
+
158
+ <FormKit
159
+ type="hidden"
160
+ name="type"
161
+ v-model="formData.type"
162
+ :value="[ 'Achievement' ]"
163
+ />
164
+
165
+ <BasicTab
166
+ v-model="formData.basic"
167
+ v-show="tab === 'basic'"
168
+ />
169
+ <DetailTab
170
+ v-model="formData.detail"
171
+ v-show="tab === 'detail'"
172
+ />
173
+ <AlignmentsTab
174
+ v-model="formData.alignments"
175
+ v-show="tab === 'alignments'"
176
+ />
177
+ <AdditionalTab
178
+ v-model="formData.additional"
179
+ v-show="tab === 'additional'"
180
+ />
181
+
182
+ <button class="btn btn-primary float-end mt-5" type="submit" :disabled="false">Save</button>
183
+ </FormKit>
184
+ </div>
185
+ </div>
186
+ </template>
187
+
188
+ <style>
189
+ .formkit-wrapper.required .form-label:before{
190
+ color: red;
191
+ content: "*";
192
+ position: absolute;
193
+ margin-left: -10px;
194
+ }
195
+
196
+ [data-invalid="true"] .form-control {
197
+ border-color: #dc3545;
198
+ padding-right: calc(1.5em + 0.75rem);
199
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
200
+ background-repeat: no-repeat;
201
+ background-position: right calc(0.375em + 0.1875rem) center;
202
+ background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
203
+ }
204
+
205
+ [data-invalid="true"] .invalid-feedback {
206
+ display: inline;
207
+ width: 100%;
208
+ margin-top: 0.25rem;
209
+ font-size: 0.875em;
210
+ color: #dc3545;
211
+ }
212
+ </style>
@@ -0,0 +1,116 @@
1
+ <script setup>
2
+ import {onBeforeMount, onMounted, ref, watch} from "vue";
3
+
4
+ const props = defineProps({
5
+ help: {
6
+ type: String,
7
+ default: 'An image that represents the credential. Must be a PNG or SVG image.'
8
+ }
9
+ })
10
+
11
+ const model = defineModel({ default: { id: '', type: 'Image', caption: ''} });
12
+ const emit = defineEmits(['update:modelValue']);
13
+ const image = ref('');
14
+ const caption = ref('');
15
+
16
+ onBeforeMount(() => {
17
+ // console.log('Mounted', model.value);
18
+ image.value = model.value.id || null;
19
+ caption.value = model.value.caption || null;
20
+ })
21
+
22
+ watch(caption, (newCaption) => {
23
+ // console.log('watching caption', newCaption);
24
+ model.value.caption = newCaption;
25
+ emit('update:modelValue', model.value);
26
+ });
27
+
28
+ watch(image, (newImage) => {
29
+ // console.log('watching', newImage);
30
+ model.value.id = newImage;
31
+ model.value.type = newImage ? 'Image' : null;
32
+ emit('update:modelValue', model.value);
33
+ });
34
+
35
+ function handleImage(e) {
36
+ // console.log('handle image');
37
+
38
+ if (0 === Object.keys(e).length) {
39
+ // console.log('no keys');
40
+ image.value = null;
41
+ caption.value = null;
42
+
43
+ return;
44
+ }
45
+
46
+ const file = e[0].file;
47
+ const reader = new FileReader();
48
+
49
+ reader.onerror = (e) => {
50
+ // console.log('image error');
51
+ image.value = null;
52
+ };
53
+ reader.onload = (e) => {
54
+ // Use the loaded image data to set the badgeImage
55
+ image.value = reader.result;
56
+ // console.log('onload');
57
+
58
+ if (image.value.substring(0, 11) !== 'data:image/') {
59
+ // console.log(image.value);
60
+ image.value = null;
61
+
62
+ return false;
63
+ }
64
+ };
65
+
66
+ try {
67
+ reader.readAsDataURL(file);
68
+ } catch (e) {
69
+ // console.log('error', e);
70
+ image.value = null;
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <FormKit
77
+ type="group"
78
+ ignore="true"
79
+ >
80
+ <FormKit
81
+ label="Image"
82
+ type="file"
83
+ accept="image/png, image/svg+xml"
84
+ :help="props.help"
85
+ inner-class=""
86
+ @input="handleImage"
87
+ ignore="true"
88
+ >
89
+ <template #fileList></template>
90
+ <template #suffix>
91
+ <figure class="figure ms-3 mt-2" v-show="image">
92
+ <img :src="image" alt="Image preview" class="img-thumbnail figure-img img-fluid rounded" id="cm-image-thumbnail" v-show="image">
93
+ <figcaption class="figure-caption text-center">Image preview</figcaption>
94
+ </figure>
95
+ </template>
96
+ </FormKit>
97
+
98
+ <FormKit
99
+ label="Image Caption"
100
+ type="text"
101
+ v-model="caption"
102
+ help="A caption for the image."
103
+ v-show="image"
104
+ ignore="true"
105
+ />
106
+ </FormKit>
107
+ </template>
108
+
109
+ <style scoped>
110
+ #cm-image-thumbnail {
111
+ max-width: 200px;
112
+ max-height: 200px;
113
+ object-fit: contain;
114
+ margin-bottom: 10px;
115
+ }
116
+ </style>