@xleddyl/nuxt-cms 0.1.26 → 0.1.27
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.
- package/dist/module.json +1 -1
- package/dist/runtime/app/components/cms/MediaField.vue +20 -18
- package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue +65 -0
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/MediaGallery.vue +212 -48
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -1
- package/dist/runtime/app/components/cms/MediaUpload.vue +9 -3
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -1
- package/dist/runtime/app/components/cms/Table.d.vue.ts +10 -2
- package/dist/runtime/app/components/cms/Table.vue +30 -2
- package/dist/runtime/app/components/cms/Table.vue.d.ts +10 -2
- package/dist/runtime/app/pages/admin-collection.vue +14 -3
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection.get.js +14 -4
- package/dist/runtime/server/api/media-presign.post.d.ts +1 -0
- package/dist/runtime/server/api/media-presign.post.js +7 -4
- package/dist/runtime/server/api/media.post.js +2 -1
- package/dist/runtime/server/api/media.put.js +2 -1
- package/dist/runtime/shared/index.d.ts +2 -0
- package/dist/runtime/shared/index.js +6 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div v-if="model" class="cms-card cms-media-
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
<div v-if="model" class="cms-card cms-media-field">
|
|
3
|
+
<div class="cms-media-field-preview">
|
|
4
|
+
<img v-if="kind === 'image' && url" :src="url" alt="" />
|
|
5
|
+
<video
|
|
6
|
+
v-else-if="kind === 'video' && url"
|
|
7
|
+
:src="url"
|
|
8
|
+
controls
|
|
9
|
+
preload="metadata"
|
|
10
|
+
playsinline
|
|
11
|
+
/>
|
|
12
|
+
<CmsIcon v-else :name="icon" class="size-10" />
|
|
13
|
+
</div>
|
|
14
|
+
<div class="cms-media-field-bar">
|
|
13
15
|
<CmsIcon :name="icon" class="cms-media-bar-icon size-4" />
|
|
14
16
|
<span class="cms-media-bar-name" :title="model">{{ mediaFilename(model) }}</span>
|
|
15
17
|
<CmsButton
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
label="Replace"
|
|
19
|
+
icon="arrow-path"
|
|
20
|
+
variant="subtle"
|
|
18
21
|
color="neutral"
|
|
19
22
|
size="xs"
|
|
20
|
-
aria-label="Change"
|
|
21
23
|
@click="openGallery"
|
|
22
24
|
/>
|
|
23
25
|
<CmsButton
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
label="Remove"
|
|
27
|
+
icon="trash"
|
|
28
|
+
variant="subtle"
|
|
29
|
+
color="error"
|
|
27
30
|
size="xs"
|
|
28
|
-
aria-label="Remove"
|
|
29
31
|
@click="clear"
|
|
30
32
|
/>
|
|
31
33
|
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
folders: string[];
|
|
3
|
+
};
|
|
4
|
+
type __VLS_ModelProps = {
|
|
5
|
+
modelValue?: string | null;
|
|
6
|
+
};
|
|
7
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
8
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
9
|
+
"update:modelValue": (value: string | null) => any;
|
|
10
|
+
create: (folder: string) => any;
|
|
11
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
12
|
+
"onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
|
|
13
|
+
onCreate?: ((folder: string) => any) | undefined;
|
|
14
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
15
|
+
declare const _default: typeof __VLS_export;
|
|
16
|
+
export default _default;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="cms-folder-picker">
|
|
3
|
+
<button
|
|
4
|
+
type="button"
|
|
5
|
+
class="cms-pill"
|
|
6
|
+
:class="{ 'is-active': model === null }"
|
|
7
|
+
@click="model = null"
|
|
8
|
+
>
|
|
9
|
+
<CmsIcon name="circle-stack" class="size-3" />Root
|
|
10
|
+
</button>
|
|
11
|
+
<button
|
|
12
|
+
v-for="name in folders"
|
|
13
|
+
:key="name"
|
|
14
|
+
type="button"
|
|
15
|
+
class="cms-pill"
|
|
16
|
+
:class="{ 'is-active': model === name }"
|
|
17
|
+
@click="model = name"
|
|
18
|
+
>
|
|
19
|
+
<CmsIcon name="folder" class="size-3" />{{ name }}
|
|
20
|
+
</button>
|
|
21
|
+
<template v-if="creating">
|
|
22
|
+
<CmsInput
|
|
23
|
+
v-model="draft"
|
|
24
|
+
size="sm"
|
|
25
|
+
autofocus
|
|
26
|
+
placeholder="Folder name"
|
|
27
|
+
class="cms-folder-picker-input"
|
|
28
|
+
@keydown.enter.prevent="commit"
|
|
29
|
+
@keydown.esc="cancel"
|
|
30
|
+
/>
|
|
31
|
+
<CmsButton label="Add" size="xs" :disabled="!normalized" @click="commit" />
|
|
32
|
+
<CmsButton label="Cancel" size="xs" variant="ghost" color="neutral" @click="cancel" />
|
|
33
|
+
</template>
|
|
34
|
+
<button v-else type="button" class="cms-pill" @click="startCreating">
|
|
35
|
+
<CmsIcon name="folder-plus" class="size-3" />New folder
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup>
|
|
41
|
+
import { computed, ref } from "#imports";
|
|
42
|
+
import { normalizeMediaFolder } from "#nuxt-cms";
|
|
43
|
+
defineProps({
|
|
44
|
+
folders: { type: Array, required: true }
|
|
45
|
+
});
|
|
46
|
+
const emit = defineEmits(["create"]);
|
|
47
|
+
const model = defineModel({ type: [String, null], ...{ default: null } });
|
|
48
|
+
const creating = ref(false);
|
|
49
|
+
const draft = ref("");
|
|
50
|
+
const normalized = computed(() => normalizeMediaFolder(draft.value));
|
|
51
|
+
function startCreating() {
|
|
52
|
+
creating.value = true;
|
|
53
|
+
}
|
|
54
|
+
function cancel() {
|
|
55
|
+
creating.value = false;
|
|
56
|
+
draft.value = "";
|
|
57
|
+
}
|
|
58
|
+
function commit() {
|
|
59
|
+
const folder = normalized.value;
|
|
60
|
+
if (!folder) return;
|
|
61
|
+
emit("create", folder);
|
|
62
|
+
model.value = folder;
|
|
63
|
+
cancel();
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
folders: string[];
|
|
3
|
+
};
|
|
4
|
+
type __VLS_ModelProps = {
|
|
5
|
+
modelValue?: string | null;
|
|
6
|
+
};
|
|
7
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
8
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
9
|
+
"update:modelValue": (value: string | null) => any;
|
|
10
|
+
create: (folder: string) => any;
|
|
11
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
12
|
+
"onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
|
|
13
|
+
onCreate?: ((folder: string) => any) | undefined;
|
|
14
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
15
|
+
declare const _default: typeof __VLS_export;
|
|
16
|
+
export default _default;
|
|
@@ -7,39 +7,58 @@
|
|
|
7
7
|
/>
|
|
8
8
|
|
|
9
9
|
<div v-else class="cms-media-gallery">
|
|
10
|
-
<
|
|
11
|
-
v-if="!readOnly"
|
|
12
|
-
:multiple="!selectable"
|
|
13
|
-
:dense="selectable"
|
|
14
|
-
:media-type="mediaType"
|
|
15
|
-
:accept="accept"
|
|
16
|
-
@uploaded="onUploaded"
|
|
17
|
-
/>
|
|
10
|
+
<CmsInput v-model="search" icon="magnifying-glass" placeholder="Search media…" />
|
|
18
11
|
|
|
19
|
-
<div v-if="
|
|
20
|
-
<
|
|
21
|
-
v-for="f in filters"
|
|
22
|
-
:key="f"
|
|
23
|
-
type="button"
|
|
24
|
-
class="cms-pill"
|
|
25
|
-
:class="{ 'is-active': filter === f }"
|
|
26
|
-
@click="filter = f"
|
|
27
|
-
>
|
|
28
|
-
{{ filterLabels[f] }}
|
|
29
|
-
</button>
|
|
30
|
-
<template v-if="folders.length">
|
|
31
|
-
<span class="cms-media-filter-separator" />
|
|
12
|
+
<div v-if="showTypeFilters || folderChips.length || !readOnly" class="cms-media-filters">
|
|
13
|
+
<template v-if="showTypeFilters">
|
|
32
14
|
<button
|
|
33
|
-
v-for="f in
|
|
15
|
+
v-for="f in filters"
|
|
34
16
|
:key="f"
|
|
35
17
|
type="button"
|
|
36
18
|
class="cms-pill"
|
|
37
|
-
:class="{ 'is-active':
|
|
38
|
-
@click="
|
|
19
|
+
:class="{ 'is-active': filter === f }"
|
|
20
|
+
@click="filter = f"
|
|
39
21
|
>
|
|
40
|
-
|
|
22
|
+
{{ filterLabels[f] }}
|
|
41
23
|
</button>
|
|
42
24
|
</template>
|
|
25
|
+
<template v-if="folderChips.length">
|
|
26
|
+
<span v-if="showTypeFilters" class="cms-media-filter-separator" />
|
|
27
|
+
<span
|
|
28
|
+
v-for="chip in folderChips"
|
|
29
|
+
:key="chip.name"
|
|
30
|
+
class="cms-pill-group"
|
|
31
|
+
:class="{ 'is-active': folder === chip.name }"
|
|
32
|
+
>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class="cms-pill"
|
|
36
|
+
:class="{ 'is-active': folder === chip.name, 'is-empty': chip.empty }"
|
|
37
|
+
@click="folder = folder === chip.name ? null : chip.name"
|
|
38
|
+
>
|
|
39
|
+
<CmsIcon name="folder" class="size-3" />{{ chip.name }}
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
v-if="chip.empty && !readOnly"
|
|
43
|
+
type="button"
|
|
44
|
+
class="cms-pill-discard"
|
|
45
|
+
aria-label="Discard empty folder"
|
|
46
|
+
@click="discardFolder(chip.name)"
|
|
47
|
+
>
|
|
48
|
+
<CmsIcon name="x-mark" class="size-3" />
|
|
49
|
+
</button>
|
|
50
|
+
</span>
|
|
51
|
+
</template>
|
|
52
|
+
<div v-if="!readOnly" class="cms-media-filters-actions">
|
|
53
|
+
<CmsButton
|
|
54
|
+
label="New folder"
|
|
55
|
+
icon="folder-plus"
|
|
56
|
+
variant="subtle"
|
|
57
|
+
size="sm"
|
|
58
|
+
@click="openNewFolder"
|
|
59
|
+
/>
|
|
60
|
+
<CmsButton icon="plus" size="sm" aria-label="Upload media" @click="uploadOpen = true" />
|
|
61
|
+
</div>
|
|
43
62
|
</div>
|
|
44
63
|
|
|
45
64
|
<CmsSpinner v-if="loading" />
|
|
@@ -50,9 +69,12 @@
|
|
|
50
69
|
v-for="item in paged"
|
|
51
70
|
:key="item.key"
|
|
52
71
|
:type="selectable ? 'button' : void 0"
|
|
72
|
+
:role="!selectable && !readOnly ? 'button' : void 0"
|
|
73
|
+
:tabindex="!selectable && !readOnly ? 0 : void 0"
|
|
53
74
|
class="cms-card cms-media-tile"
|
|
54
|
-
:class="{ 'is-selectable': selectable }"
|
|
55
|
-
@click="
|
|
75
|
+
:class="{ 'is-selectable': selectable || !readOnly }"
|
|
76
|
+
@click="onTileClick(item)"
|
|
77
|
+
@keydown.enter="!selectable && !readOnly && openEdit(item)"
|
|
56
78
|
>
|
|
57
79
|
<div class="cms-media-preview">
|
|
58
80
|
<img
|
|
@@ -69,24 +91,14 @@
|
|
|
69
91
|
playsinline
|
|
70
92
|
/>
|
|
71
93
|
<CmsIcon v-else :name="mediaIconFor(item.type)" class="size-8" />
|
|
72
|
-
<div v-if="!selectable" class="cms-media-actions">
|
|
73
|
-
<CmsButton
|
|
74
|
-
v-if="!readOnly"
|
|
75
|
-
icon="pencil-square"
|
|
76
|
-
size="xs"
|
|
77
|
-
color="neutral"
|
|
78
|
-
variant="solid"
|
|
79
|
-
aria-label="Edit details"
|
|
80
|
-
@click="openEdit(item)"
|
|
81
|
-
/>
|
|
94
|
+
<div v-if="!selectable && !readOnly" class="cms-media-actions">
|
|
82
95
|
<CmsButton
|
|
83
|
-
v-if="!readOnly"
|
|
84
96
|
icon="trash"
|
|
85
97
|
size="xs"
|
|
86
98
|
color="error"
|
|
87
99
|
variant="solid"
|
|
88
100
|
aria-label="Delete"
|
|
89
|
-
@click="remove(item)"
|
|
101
|
+
@click.stop="remove(item)"
|
|
90
102
|
/>
|
|
91
103
|
</div>
|
|
92
104
|
<div class="cms-media-info">
|
|
@@ -100,6 +112,9 @@
|
|
|
100
112
|
<div class="cms-media-name" :title="item.key">
|
|
101
113
|
{{ mediaFilename(item.key) }}
|
|
102
114
|
</div>
|
|
115
|
+
<div v-if="item.folder" class="cms-media-folder">
|
|
116
|
+
<CmsIcon name="folder" class="size-3" />{{ item.folder }}
|
|
117
|
+
</div>
|
|
103
118
|
</div>
|
|
104
119
|
</component>
|
|
105
120
|
</div>
|
|
@@ -108,12 +123,27 @@
|
|
|
108
123
|
<CmsButton label="Retry" icon="arrow-path" variant="subtle" @click="reload" />
|
|
109
124
|
</CmsEmptyState>
|
|
110
125
|
|
|
126
|
+
<CmsEmptyState
|
|
127
|
+
v-else-if="items.length"
|
|
128
|
+
icon="magnifying-glass"
|
|
129
|
+
title="No matching media"
|
|
130
|
+
body="Try a different search or filter."
|
|
131
|
+
/>
|
|
132
|
+
|
|
111
133
|
<CmsEmptyState
|
|
112
134
|
v-else
|
|
113
135
|
icon="photo"
|
|
114
136
|
:title="readOnly ? 'No media' : 'No media yet'"
|
|
115
137
|
:body="readOnly ? 'No media registered.' : 'Files you upload will show up here.'"
|
|
116
|
-
|
|
138
|
+
>
|
|
139
|
+
<CmsButton
|
|
140
|
+
v-if="!readOnly"
|
|
141
|
+
label="Upload"
|
|
142
|
+
icon="plus"
|
|
143
|
+
variant="subtle"
|
|
144
|
+
@click="uploadOpen = true"
|
|
145
|
+
/>
|
|
146
|
+
</CmsEmptyState>
|
|
117
147
|
|
|
118
148
|
<CmsPagination
|
|
119
149
|
v-if="!loading && visible.length"
|
|
@@ -122,11 +152,60 @@
|
|
|
122
152
|
:items-per-page="PAGE_SIZE"
|
|
123
153
|
/>
|
|
124
154
|
|
|
155
|
+
<CmsModal v-if="!readOnly" v-model:open="uploadOpen" title="Upload media">
|
|
156
|
+
<template #body>
|
|
157
|
+
<div class="cms-form">
|
|
158
|
+
<CmsFormField label="Destination folder">
|
|
159
|
+
<CmsMediaFolderPicker
|
|
160
|
+
v-model="uploadFolder"
|
|
161
|
+
:folders="folderNames"
|
|
162
|
+
@create="registerFolder"
|
|
163
|
+
/>
|
|
164
|
+
</CmsFormField>
|
|
165
|
+
<CmsMediaUpload
|
|
166
|
+
:multiple="!selectable"
|
|
167
|
+
:media-type="mediaType"
|
|
168
|
+
:accept="accept"
|
|
169
|
+
:folder="uploadFolder"
|
|
170
|
+
@uploaded="onUploaded"
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|
|
174
|
+
</CmsModal>
|
|
175
|
+
|
|
176
|
+
<CmsModal v-if="!readOnly" v-model:open="newFolderOpen" title="New folder" size="sm">
|
|
177
|
+
<template #body>
|
|
178
|
+
<div class="cms-form">
|
|
179
|
+
<CmsFormField label="Name">
|
|
180
|
+
<CmsInput
|
|
181
|
+
v-model="newFolderName"
|
|
182
|
+
autofocus
|
|
183
|
+
placeholder="e.g. blog/covers"
|
|
184
|
+
@keydown.enter.prevent="createFolder"
|
|
185
|
+
/>
|
|
186
|
+
<p class="cms-form-hint">
|
|
187
|
+
{{
|
|
188
|
+
newFolderSlug ? `Files uploaded here are stored under ${newFolderSlug}/` : "Letters, numbers and dashes. Use / for nesting."
|
|
189
|
+
}}
|
|
190
|
+
</p>
|
|
191
|
+
</CmsFormField>
|
|
192
|
+
<div class="cms-actions is-end">
|
|
193
|
+
<CmsButton
|
|
194
|
+
label="Cancel"
|
|
195
|
+
variant="ghost"
|
|
196
|
+
color="neutral"
|
|
197
|
+
@click="newFolderOpen = false"
|
|
198
|
+
/>
|
|
199
|
+
<CmsButton label="Create" :disabled="!newFolderSlug" @click="createFolder" />
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</template>
|
|
203
|
+
</CmsModal>
|
|
204
|
+
|
|
125
205
|
<CmsModal
|
|
126
206
|
v-if="!readOnly"
|
|
127
207
|
:open="!!editing"
|
|
128
208
|
:title="editing ? mediaFilename(editing.key) : ''"
|
|
129
|
-
size="lg"
|
|
130
209
|
@update:open="
|
|
131
210
|
(open) => {
|
|
132
211
|
if (!open) editing = null;
|
|
@@ -139,9 +218,19 @@
|
|
|
139
218
|
<CmsInput v-model="editAlt" />
|
|
140
219
|
</CmsFormField>
|
|
141
220
|
<CmsFormField label="Folder">
|
|
142
|
-
<
|
|
221
|
+
<CmsMediaFolderPicker
|
|
222
|
+
v-model="editFolder"
|
|
223
|
+
:folders="folderNames"
|
|
224
|
+
@create="registerFolder"
|
|
225
|
+
/>
|
|
143
226
|
</CmsFormField>
|
|
144
227
|
<div class="cms-actions is-end">
|
|
228
|
+
<CmsButton
|
|
229
|
+
label="Cancel"
|
|
230
|
+
variant="ghost"
|
|
231
|
+
color="neutral"
|
|
232
|
+
@click="editing = null"
|
|
233
|
+
/>
|
|
145
234
|
<CmsButton label="Save" :loading="editSaving" @click="saveEdit" />
|
|
146
235
|
</div>
|
|
147
236
|
</div>
|
|
@@ -152,7 +241,7 @@
|
|
|
152
241
|
|
|
153
242
|
<script setup>
|
|
154
243
|
import { computed, onMounted, ref, watch } from "#imports";
|
|
155
|
-
import { MEDIA_TYPES, mediaFilename, mediaIconFor } from "#nuxt-cms";
|
|
244
|
+
import { MEDIA_TYPES, mediaFilename, mediaIconFor, normalizeMediaFolder } from "#nuxt-cms";
|
|
156
245
|
import { useCmsConfirm } from "../../composables/cms-confirm";
|
|
157
246
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
158
247
|
import { useCmsToast } from "../../composables/cms-toast";
|
|
@@ -173,6 +262,7 @@ const toast = useCmsToast();
|
|
|
173
262
|
const runtime = useCmsRuntime();
|
|
174
263
|
const readOnly = computed(() => runtime.mediaStorage === "local");
|
|
175
264
|
const endpoint = "/api/cms/admin/media";
|
|
265
|
+
const DRAFT_FOLDERS_KEY = "nuxt-cms:media-folders";
|
|
176
266
|
const items = ref([]);
|
|
177
267
|
const loading = ref(true);
|
|
178
268
|
const errorCode = ref(null);
|
|
@@ -196,20 +286,67 @@ const restricted = computed(
|
|
|
196
286
|
);
|
|
197
287
|
const filters = ["all", ...MEDIA_TYPES];
|
|
198
288
|
const filter = ref("all");
|
|
199
|
-
const
|
|
289
|
+
const showTypeFilters = computed(() => !restricted.value && items.value.length > 0);
|
|
290
|
+
const search = ref("");
|
|
291
|
+
const draftFolders = ref([]);
|
|
292
|
+
onMounted(() => {
|
|
293
|
+
try {
|
|
294
|
+
const stored = JSON.parse(localStorage.getItem(DRAFT_FOLDERS_KEY) ?? "[]");
|
|
295
|
+
if (Array.isArray(stored)) draftFolders.value = stored.filter((f) => typeof f === "string");
|
|
296
|
+
} catch {
|
|
297
|
+
draftFolders.value = [];
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
function persistDraftFolders() {
|
|
301
|
+
try {
|
|
302
|
+
localStorage.setItem(DRAFT_FOLDERS_KEY, JSON.stringify(draftFolders.value));
|
|
303
|
+
} catch {
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const usedFolders = computed(() => {
|
|
200
307
|
const set = new Set(items.value.map((item) => item.folder).filter((f) => !!f));
|
|
201
308
|
return [...set].sort();
|
|
202
309
|
});
|
|
310
|
+
const folderNames = computed(
|
|
311
|
+
() => [.../* @__PURE__ */ new Set([...usedFolders.value, ...draftFolders.value])].sort()
|
|
312
|
+
);
|
|
313
|
+
const folderChips = computed(
|
|
314
|
+
() => folderNames.value.map((name) => ({ name, empty: !usedFolders.value.includes(name) }))
|
|
315
|
+
);
|
|
203
316
|
const folder = ref(null);
|
|
317
|
+
function registerFolder(name) {
|
|
318
|
+
if (!draftFolders.value.includes(name) && !usedFolders.value.includes(name)) {
|
|
319
|
+
draftFolders.value = [...draftFolders.value, name];
|
|
320
|
+
persistDraftFolders();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function discardFolder(name) {
|
|
324
|
+
draftFolders.value = draftFolders.value.filter((f) => f !== name);
|
|
325
|
+
persistDraftFolders();
|
|
326
|
+
if (folder.value === name) folder.value = null;
|
|
327
|
+
}
|
|
328
|
+
watch(usedFolders, (used) => {
|
|
329
|
+
const next = draftFolders.value.filter((f) => !used.includes(f));
|
|
330
|
+
if (next.length !== draftFolders.value.length) {
|
|
331
|
+
draftFolders.value = next;
|
|
332
|
+
persistDraftFolders();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
204
335
|
const visible = computed(() => {
|
|
205
336
|
const active = restricted.value ?? (filter.value === "all" ? null : filter.value);
|
|
206
337
|
let list = active ? items.value.filter((item) => item.type === active) : items.value;
|
|
207
338
|
if (folder.value) list = list.filter((item) => item.folder === folder.value);
|
|
339
|
+
const query = search.value.trim().toLowerCase();
|
|
340
|
+
if (query) {
|
|
341
|
+
list = list.filter(
|
|
342
|
+
(item) => [item.key, item.alt, item.folder].some((value) => value?.toLowerCase().includes(query))
|
|
343
|
+
);
|
|
344
|
+
}
|
|
208
345
|
return list;
|
|
209
346
|
});
|
|
210
347
|
const PAGE_SIZE = 24;
|
|
211
348
|
const page = ref(1);
|
|
212
|
-
watch([filter, folder], () => {
|
|
349
|
+
watch([filter, folder, search], () => {
|
|
213
350
|
page.value = 1;
|
|
214
351
|
});
|
|
215
352
|
watch(visible, (list) => {
|
|
@@ -219,21 +356,48 @@ watch(visible, (list) => {
|
|
|
219
356
|
const paged = computed(
|
|
220
357
|
() => visible.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
|
|
221
358
|
);
|
|
359
|
+
const uploadOpen = ref(false);
|
|
360
|
+
const uploadFolder = ref(null);
|
|
361
|
+
watch(uploadOpen, (open) => {
|
|
362
|
+
if (open) uploadFolder.value = folder.value;
|
|
363
|
+
});
|
|
222
364
|
function onUploaded(uploaded) {
|
|
365
|
+
uploadOpen.value = false;
|
|
223
366
|
if (props.selectable && uploaded[0]) {
|
|
224
367
|
emit("select", uploaded[0]);
|
|
225
368
|
return;
|
|
226
369
|
}
|
|
227
370
|
void reload();
|
|
228
371
|
}
|
|
372
|
+
const newFolderOpen = ref(false);
|
|
373
|
+
const newFolderName = ref("");
|
|
374
|
+
const newFolderSlug = computed(() => normalizeMediaFolder(newFolderName.value));
|
|
375
|
+
function openNewFolder() {
|
|
376
|
+
newFolderName.value = "";
|
|
377
|
+
newFolderOpen.value = true;
|
|
378
|
+
}
|
|
379
|
+
function createFolder() {
|
|
380
|
+
const name = newFolderSlug.value;
|
|
381
|
+
if (!name) return;
|
|
382
|
+
registerFolder(name);
|
|
383
|
+
folder.value = name;
|
|
384
|
+
newFolderOpen.value = false;
|
|
385
|
+
}
|
|
229
386
|
const editing = ref(null);
|
|
230
387
|
const editAlt = ref("");
|
|
231
|
-
const editFolder = ref(
|
|
388
|
+
const editFolder = ref(null);
|
|
232
389
|
const editSaving = ref(false);
|
|
390
|
+
function onTileClick(item) {
|
|
391
|
+
if (props.selectable) {
|
|
392
|
+
emit("select", { key: item.key, url: item.url });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (!readOnly.value) openEdit(item);
|
|
396
|
+
}
|
|
233
397
|
function openEdit(item) {
|
|
234
398
|
editing.value = item;
|
|
235
399
|
editAlt.value = item.alt ?? "";
|
|
236
|
-
editFolder.value = item.folder ??
|
|
400
|
+
editFolder.value = item.folder ?? null;
|
|
237
401
|
}
|
|
238
402
|
async function saveEdit() {
|
|
239
403
|
if (!editing.value) return;
|
|
@@ -244,7 +408,7 @@ async function saveEdit() {
|
|
|
244
408
|
body: {
|
|
245
409
|
id: editing.value.id,
|
|
246
410
|
alt: editAlt.value.trim() || null,
|
|
247
|
-
folder: editFolder.value
|
|
411
|
+
folder: editFolder.value
|
|
248
412
|
}
|
|
249
413
|
});
|
|
250
414
|
editing.value = null;
|
|
@@ -3,7 +3,7 @@ type __VLS_Props = {
|
|
|
3
3
|
multiple?: boolean;
|
|
4
4
|
mediaType?: MediaType;
|
|
5
5
|
accept?: string[];
|
|
6
|
-
|
|
6
|
+
folder?: string | null;
|
|
7
7
|
};
|
|
8
8
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
9
9
|
uploaded: (items: MediaItem[]) => any;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<button
|
|
3
3
|
type="button"
|
|
4
4
|
class="cms-dropzone"
|
|
5
|
-
:class="{ 'is-
|
|
5
|
+
:class="{ 'is-over': dragOver, 'is-busy': uploading }"
|
|
6
6
|
:disabled="uploading"
|
|
7
7
|
@click="input?.click()"
|
|
8
8
|
@dragover.prevent="dragOver = true"
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
uploading ? `Uploading ${Math.min(done + 1, total)} of ${total}\u2026` : multiple ? "Drop files here or click to browse" : "Drop a file here or click to browse"
|
|
29
29
|
}}
|
|
30
30
|
</span>
|
|
31
|
+
<span v-if="!uploading" class="cms-dropzone-hint">
|
|
32
|
+
<CmsIcon :name="folder ? 'folder' : 'circle-stack'" class="size-3.5" />
|
|
33
|
+
{{ folder ?? "Library root" }}
|
|
34
|
+
</span>
|
|
31
35
|
</button>
|
|
32
36
|
</template>
|
|
33
37
|
|
|
@@ -38,7 +42,7 @@ const props = defineProps({
|
|
|
38
42
|
multiple: { type: Boolean, required: false },
|
|
39
43
|
mediaType: { type: null, required: false, default: "file" },
|
|
40
44
|
accept: { type: Array, required: false },
|
|
41
|
-
|
|
45
|
+
folder: { type: [String, null], required: false, default: null }
|
|
42
46
|
});
|
|
43
47
|
const emit = defineEmits(["uploaded"]);
|
|
44
48
|
const toast = useCmsToast();
|
|
@@ -77,7 +81,8 @@ async function uploadOne(file) {
|
|
|
77
81
|
body: {
|
|
78
82
|
filename: file.name,
|
|
79
83
|
contentType: file.type || "application/octet-stream",
|
|
80
|
-
size: file.size
|
|
84
|
+
size: file.size,
|
|
85
|
+
folder: props.folder
|
|
81
86
|
}
|
|
82
87
|
});
|
|
83
88
|
const res = await fetch(presign.uploadUrl, {
|
|
@@ -90,6 +95,7 @@ async function uploadOne(file) {
|
|
|
90
95
|
method: "POST",
|
|
91
96
|
body: {
|
|
92
97
|
key: presign.key,
|
|
98
|
+
folder: presign.folder,
|
|
93
99
|
mime: file.type || null,
|
|
94
100
|
size: file.size,
|
|
95
101
|
...await imageDimensions(file)
|
|
@@ -3,7 +3,7 @@ type __VLS_Props = {
|
|
|
3
3
|
multiple?: boolean;
|
|
4
4
|
mediaType?: MediaType;
|
|
5
5
|
accept?: string[];
|
|
6
|
-
|
|
6
|
+
folder?: string | null;
|
|
7
7
|
};
|
|
8
8
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
9
9
|
uploaded: (items: MediaItem[]) => any;
|
|
@@ -5,6 +5,11 @@ interface Column {
|
|
|
5
5
|
accessorFn?: (row: Row) => unknown;
|
|
6
6
|
header?: string;
|
|
7
7
|
reorderable?: boolean;
|
|
8
|
+
sortable?: boolean;
|
|
9
|
+
}
|
|
10
|
+
interface TableSort {
|
|
11
|
+
key: string;
|
|
12
|
+
order: 'asc' | 'desc';
|
|
8
13
|
}
|
|
9
14
|
type __VLS_Props = {
|
|
10
15
|
data: Row[];
|
|
@@ -12,15 +17,16 @@ type __VLS_Props = {
|
|
|
12
17
|
};
|
|
13
18
|
type __VLS_ModelProps = {
|
|
14
19
|
'columnVisibility'?: Record<string, boolean>;
|
|
20
|
+
'sort'?: TableSort | null;
|
|
15
21
|
};
|
|
16
22
|
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
17
|
-
declare var
|
|
23
|
+
declare var __VLS_7: `${string}-cell`, __VLS_8: {
|
|
18
24
|
row: {
|
|
19
25
|
original: Row;
|
|
20
26
|
};
|
|
21
27
|
};
|
|
22
28
|
type __VLS_Slots = {} & {
|
|
23
|
-
[K in NonNullable<typeof
|
|
29
|
+
[K in NonNullable<typeof __VLS_7>]?: (props: typeof __VLS_8) => any;
|
|
24
30
|
};
|
|
25
31
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
26
32
|
select: (event: Event, row: {
|
|
@@ -28,12 +34,14 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {
|
|
|
28
34
|
}) => any;
|
|
29
35
|
reorder: (from: string, to: string) => any;
|
|
30
36
|
"update:columnVisibility": (value: Record<string, boolean>) => any;
|
|
37
|
+
"update:sort": (value: TableSort | null) => any;
|
|
31
38
|
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
32
39
|
onSelect?: ((event: Event, row: {
|
|
33
40
|
original: Row;
|
|
34
41
|
}) => any) | undefined;
|
|
35
42
|
onReorder?: ((from: string, to: string) => any) | undefined;
|
|
36
43
|
"onUpdate:columnVisibility"?: ((value: Record<string, boolean>) => any) | undefined;
|
|
44
|
+
"onUpdate:sort"?: ((value: TableSort | null) => any) | undefined;
|
|
37
45
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
38
46
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
39
47
|
declare const _default: typeof __VLS_export;
|
|
@@ -9,16 +9,27 @@
|
|
|
9
9
|
:key="columnId(column)"
|
|
10
10
|
:class="{
|
|
11
11
|
'is-dragging': draggingId === columnId(column),
|
|
12
|
-
'is-drop-target': dropTargetId === columnId(column)
|
|
12
|
+
'is-drop-target': dropTargetId === columnId(column),
|
|
13
|
+
'is-sortable': column.sortable,
|
|
14
|
+
'is-sorted': sort?.key === columnId(column)
|
|
13
15
|
}"
|
|
14
16
|
:draggable="column.reorderable || void 0"
|
|
17
|
+
:aria-sort="ariaSort(column)"
|
|
18
|
+
@click="toggleSort(column)"
|
|
15
19
|
@dragstart="onDragStart($event, column)"
|
|
16
20
|
@dragover="onDragOver($event, column)"
|
|
17
21
|
@dragleave="onDragLeave(column)"
|
|
18
22
|
@drop="onDrop($event, column)"
|
|
19
23
|
@dragend="resetDrag"
|
|
20
24
|
>
|
|
21
|
-
|
|
25
|
+
<span class="cms-th-label">
|
|
26
|
+
{{ column.header }}
|
|
27
|
+
<CmsIcon
|
|
28
|
+
v-if="column.sortable"
|
|
29
|
+
:name="sortIcon(column)"
|
|
30
|
+
class="cms-th-sort size-3"
|
|
31
|
+
/>
|
|
32
|
+
</span>
|
|
22
33
|
</th>
|
|
23
34
|
</tr>
|
|
24
35
|
</thead>
|
|
@@ -48,10 +59,27 @@ const props = defineProps({
|
|
|
48
59
|
columns: { type: Array, required: true }
|
|
49
60
|
});
|
|
50
61
|
const visibility = defineModel("columnVisibility", { type: Object, ...{ default: () => ({}) } });
|
|
62
|
+
const sort = defineModel("sort", { type: [Object, null], ...{ default: null } });
|
|
51
63
|
const emit = defineEmits(["select", "reorder"]);
|
|
52
64
|
function columnId(column) {
|
|
53
65
|
return column.id ?? column.accessorKey ?? "";
|
|
54
66
|
}
|
|
67
|
+
function toggleSort(column) {
|
|
68
|
+
if (!column.sortable) return;
|
|
69
|
+
const key = columnId(column);
|
|
70
|
+
if (sort.value?.key !== key) sort.value = { key, order: "asc" };
|
|
71
|
+
else if (sort.value.order === "asc") sort.value = { key, order: "desc" };
|
|
72
|
+
else sort.value = null;
|
|
73
|
+
}
|
|
74
|
+
function sortIcon(column) {
|
|
75
|
+
if (sort.value?.key !== columnId(column)) return "arrows-up-down";
|
|
76
|
+
return sort.value.order === "asc" ? "arrow-small-up" : "arrow-small-down";
|
|
77
|
+
}
|
|
78
|
+
function ariaSort(column) {
|
|
79
|
+
if (!column.sortable) return void 0;
|
|
80
|
+
if (sort.value?.key !== columnId(column)) return "none";
|
|
81
|
+
return sort.value.order === "asc" ? "ascending" : "descending";
|
|
82
|
+
}
|
|
55
83
|
const draggingId = ref(null);
|
|
56
84
|
const dropTargetId = ref(null);
|
|
57
85
|
function resetDrag() {
|
|
@@ -5,6 +5,11 @@ interface Column {
|
|
|
5
5
|
accessorFn?: (row: Row) => unknown;
|
|
6
6
|
header?: string;
|
|
7
7
|
reorderable?: boolean;
|
|
8
|
+
sortable?: boolean;
|
|
9
|
+
}
|
|
10
|
+
interface TableSort {
|
|
11
|
+
key: string;
|
|
12
|
+
order: 'asc' | 'desc';
|
|
8
13
|
}
|
|
9
14
|
type __VLS_Props = {
|
|
10
15
|
data: Row[];
|
|
@@ -12,15 +17,16 @@ type __VLS_Props = {
|
|
|
12
17
|
};
|
|
13
18
|
type __VLS_ModelProps = {
|
|
14
19
|
'columnVisibility'?: Record<string, boolean>;
|
|
20
|
+
'sort'?: TableSort | null;
|
|
15
21
|
};
|
|
16
22
|
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
17
|
-
declare var
|
|
23
|
+
declare var __VLS_7: `${string}-cell`, __VLS_8: {
|
|
18
24
|
row: {
|
|
19
25
|
original: Row;
|
|
20
26
|
};
|
|
21
27
|
};
|
|
22
28
|
type __VLS_Slots = {} & {
|
|
23
|
-
[K in NonNullable<typeof
|
|
29
|
+
[K in NonNullable<typeof __VLS_7>]?: (props: typeof __VLS_8) => any;
|
|
24
30
|
};
|
|
25
31
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
26
32
|
select: (event: Event, row: {
|
|
@@ -28,12 +34,14 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {
|
|
|
28
34
|
}) => any;
|
|
29
35
|
reorder: (from: string, to: string) => any;
|
|
30
36
|
"update:columnVisibility": (value: Record<string, boolean>) => any;
|
|
37
|
+
"update:sort": (value: TableSort | null) => any;
|
|
31
38
|
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
32
39
|
onSelect?: ((event: Event, row: {
|
|
33
40
|
original: Row;
|
|
34
41
|
}) => any) | undefined;
|
|
35
42
|
onReorder?: ((from: string, to: string) => any) | undefined;
|
|
36
43
|
"onUpdate:columnVisibility"?: ((value: Record<string, boolean>) => any) | undefined;
|
|
44
|
+
"onUpdate:sort"?: ((value: TableSort | null) => any) | undefined;
|
|
37
45
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
38
46
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
39
47
|
declare const _default: typeof __VLS_export;
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
<CmsTable
|
|
51
51
|
v-if="rows.length"
|
|
52
52
|
v-model:column-visibility="columnVisibility"
|
|
53
|
+
v-model:sort="sort"
|
|
53
54
|
:data="rows"
|
|
54
55
|
:columns="columns"
|
|
55
56
|
@select="onSelect"
|
|
@@ -156,10 +157,15 @@ watch(search, (value) => {
|
|
|
156
157
|
page.value = 1;
|
|
157
158
|
}, 300);
|
|
158
159
|
});
|
|
160
|
+
const sort = ref(null);
|
|
161
|
+
watch(sort, () => {
|
|
162
|
+
page.value = 1;
|
|
163
|
+
});
|
|
159
164
|
const listQuery = computed(() => ({
|
|
160
165
|
limit: PAGE_SIZE,
|
|
161
166
|
offset: (page.value - 1) * PAGE_SIZE,
|
|
162
|
-
...searchTerm.value ? { search: searchTerm.value } : {}
|
|
167
|
+
...searchTerm.value ? { search: searchTerm.value } : {},
|
|
168
|
+
...sort.value ? { sort: sort.value.key, order: sort.value.order } : {}
|
|
163
169
|
}));
|
|
164
170
|
const { data, refresh, error, status } = await useFetch(endpoint, {
|
|
165
171
|
query: config.kind === "collection" ? listQuery : void 0
|
|
@@ -257,14 +263,19 @@ const orderedKeys = computed(() => {
|
|
|
257
263
|
const known = columnOrder.value.filter((key) => fieldKeys.includes(key));
|
|
258
264
|
return [...known, ...fieldKeys.filter((key) => !known.includes(key))];
|
|
259
265
|
});
|
|
266
|
+
function isSortable(field) {
|
|
267
|
+
if (field.type === "blocks") return false;
|
|
268
|
+
return !(field.type === "relation" && field.cardinality === "many-to-many");
|
|
269
|
+
}
|
|
260
270
|
const columns = computed(() => [
|
|
261
271
|
...orderedKeys.value.map((key) => ({
|
|
262
272
|
id: key,
|
|
263
273
|
accessorFn: (row) => displayValue(config.fields[key], key, row[key]),
|
|
264
274
|
header: config.fields[key].label,
|
|
265
|
-
reorderable: true
|
|
275
|
+
reorderable: true,
|
|
276
|
+
sortable: isSortable(config.fields[key])
|
|
266
277
|
})),
|
|
267
|
-
...drafts ? [{ accessorKey: "status", header: "Status" }] : [],
|
|
278
|
+
...drafts ? [{ accessorKey: "status", header: "Status", sortable: true }] : [],
|
|
268
279
|
{ id: "actions", header: "" }
|
|
269
280
|
]);
|
|
270
281
|
function reorderColumns(from, to) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&family=Hanken+Grotesk:ital,wght@0,300..800;1,300..800&family=Fragment+Mono:ital@0;1&display=swap");@layer theme, base, components, utilities;@import "tailwindcss/theme.css" layer(theme) theme(inline);.cms-scope{@import "tailwindcss/preflight.css" layer(base);@import "tailwindcss/utilities.css" layer(utilities) source(none)}@source "../app";.cms-scope,body:has(.cms-scope){--cms-paper:#f5f5f5;--cms-surface:#fff;--cms-field:#f0f0f0;--cms-ink:#171717;--cms-line:#e5e5e5;--cms-line-strong:#d4d4d4;--cms-fern:#171717;--cms-sage:#ebebeb;--cms-sage-ink:#171717;--cms-shadow-soft:none;color-scheme:light;--ui-text-dimmed:#a3a3a3;--ui-text-muted:#737373;--ui-text-toned:#525252;--ui-text:#404040;--ui-text-highlighted:var(--cms-ink);--ui-text-inverted:#fff;--ui-bg:var(--cms-surface);--ui-bg-muted:var(--cms-paper);--ui-bg-elevated:var(--cms-field);--ui-bg-accented:#e0e0e0;--ui-bg-inverted:var(--cms-ink);--ui-border:var(--cms-line);--ui-border-muted:var(--cms-line);--ui-border-accented:var(--cms-line-strong);--ui-border-inverted:var(--cms-ink);--ui-primary:var(--cms-ink);--ui-error:#dc2626;--ui-radius:0.4rem;color:var(--ui-text);font-family:Hanken Grotesk,ui-sans-serif,system-ui,sans-serif;margin:0;padding:0;-webkit-font-smoothing:antialiased}body:has(.cms-scope) ::-moz-selection{background:var(--cms-sage);color:var(--cms-sage-ink)}body:has(.cms-scope) ::selection{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-display,.cms-title{font-family:Poppins,ui-sans-serif,system-ui,sans-serif;letter-spacing:-.02em}.cms-title{color:var(--ui-text-highlighted);font-weight:500}.cms-title-sm{font-size:1.25rem;line-height:1.4}.cms-title-md{font-size:2rem;line-height:1}.cms-title-lg{font-size:2.25rem;line-height:1.2}.cms-subtitle{color:var(--ui-text-muted);font-size:.875rem}.cms-accent{color:var(--cms-fern)}.cms-kicker{color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;letter-spacing:.16em;line-height:1.4;text-transform:uppercase}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]){@apply cursor-pointer transition-all duration-200 select-none}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]):active{@apply transition-none;scale:.99}.cms-canvas{background-color:var(--cms-paper)}.cms-grain:before{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='a'%3E%3CfeTurbulence baseFrequency='.8' numOctaves='2' stitchTiles='stitch' type='fractalNoise'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");content:"";inset:0;mix-blend-mode:multiply;opacity:.035;pointer-events:none;position:fixed;z-index:50}.cms-card{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft)}.cms-panel{padding:1.75rem}.cms-shell{display:flex;min-height:100vh}.cms-sidebar{border-right:1px solid var(--cms-line);display:flex;flex-direction:column;flex-shrink:0;gap:1.75rem;height:100vh;padding:1.75rem 1rem;position:sticky;top:0;width:16rem}.cms-sidebar-brand{color:var(--ui-text-highlighted);font-family:Poppins,ui-sans-serif,system-ui,sans-serif;font-size:1.375rem;font-weight:600;letter-spacing:-.02em;padding-inline:.75rem}.cms-sidebar-nav{display:flex;flex:1;flex-direction:column;gap:1.5rem;overflow-y:auto}.cms-sidebar-group{display:flex;flex-direction:column;gap:.25rem}.cms-sidebar-group>.cms-kicker{margin-bottom:.5rem;padding-inline:.75rem}.cms-sidebar-footer{border-top:1px solid var(--cms-line);display:flex;flex-direction:column;gap:.75rem;padding:1.25rem .75rem 0}.cms-main{flex:1;height:100vh;min-width:0;overflow-y:auto;padding:2.5rem}.cms-main-inner{margin-inline:auto;max-width:64rem;min-height:100%;width:100%}.cms-main-inner,.cms-page{display:flex;flex-direction:column}.cms-page{gap:1.75rem}.cms-page.is-fill{flex:1;min-height:0}.cms-page-header{align-items:flex-end;display:flex;gap:1rem;justify-content:space-between}.cms-page-heading{display:flex;flex-direction:column;gap:.5rem}.cms-toolbar{align-items:center;display:flex;gap:1.5rem}.cms-toolbar-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-actions{align-items:center;display:flex;gap:.75rem}.cms-actions.is-end{justify-content:flex-end}.cms-spinner{color:var(--ui-text-dimmed);flex:1;justify-content:center;padding-block:2.5rem}.cms-empty,.cms-spinner{align-items:center;display:flex}.cms-empty{background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;flex-direction:column;gap:.5rem;padding:3.5rem;text-align:center}.cms-empty.is-fill{flex:1;justify-content:center}.cms-empty-icon{color:var(--cms-fern);margin-bottom:.25rem}.cms-empty>.cms-btn{margin-top:.75rem}.cms-auth{align-items:center;display:flex;justify-content:center;min-height:100vh;padding:1.5rem}.cms-auth-panel{gap:2rem;max-width:24rem;width:100%}.cms-auth-header,.cms-auth-panel{display:flex;flex-direction:column}.cms-auth-header{align-items:center;gap:.75rem;text-align:center}.cms-auth-card{padding:1.75rem}.cms-navlink{align-items:center;border-radius:.75rem;color:var(--ui-text-toned);display:flex;font-size:.875rem;font-weight:500;gap:.625rem;padding:.5rem .75rem;transition:background-color .15s ease,color .15s ease}.cms-navlink:hover{background:rgba(38,33,25,.05);color:var(--ui-text-highlighted)}.cms-navlink.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-navlink-icon{color:var(--ui-text-dimmed)}.cms-navlink.is-active .cms-navlink-icon{color:var(--cms-fern)}.cms-badge{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;letter-spacing:.1em;padding:.1875rem .5rem;pointer-events:none;text-transform:uppercase;transition:background-color .15s ease,border-color .15s ease;white-space:nowrap}.cms-badge.is-published{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-badge.is-muted{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-muted)}.cms-badge.is-draft{background:transparent;border:1px dashed var(--cms-line-strong);color:var(--ui-text-muted)}.cms-dropzone{align-items:center;background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;color:var(--ui-text-muted);cursor:pointer;display:flex;flex-direction:column;gap:.5rem;justify-content:center;padding:2rem 1.5rem;transition:border-color .15s ease,background-color .15s ease,color .15s ease;width:100%}.cms-dropzone.is-over,.cms-dropzone:hover{background:var(--cms-sage);border-color:var(--cms-fern);color:var(--cms-sage-ink)}.cms-dropzone.is-dense{border-radius:.75rem;flex-direction:row;gap:.625rem;padding:.875rem 1.25rem}.cms-dropzone.is-busy{opacity:.7;pointer-events:none}.cms-pill{align-items:center;background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;flex:none;font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;gap:.35rem;letter-spacing:.12em;padding:.3rem .8rem;transition:background-color .15s ease,color .15s ease;white-space:nowrap}.cms-pill:hover{background:rgba(38,33,25,.05);color:var(--ui-text)}.cms-pill.is-active{background:var(--cms-sage);color:var(--cms-sage-ink)}@keyframes cms-rise{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}.cms-rise{animation:cms-rise .55s cubic-bezier(.22,1,.36,1) both}.cms-rise.is-delayed{animation-delay:90ms}@media (prefers-reduced-motion:reduce){.cms-rise{animation:none}}.cms-richtext{border:1px solid var(--cms-line);border-radius:.75rem;overflow:hidden}.cms-richtext-toolbar{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;flex-wrap:wrap;gap:2px;padding:4px}.cms-richtext-body{font-size:.875rem;min-height:9rem;outline:none;padding:.75rem 1rem}.cms-richtext-body h2{font-size:1.25rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body h3{font-size:1.1rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body p{margin:.35em 0}.cms-richtext-body ul{list-style:disc;padding-left:1.25rem}.cms-richtext-body ol{list-style:decimal;padding-left:1.25rem}.cms-richtext-body blockquote{border-left:3px solid var(--cms-line);color:var(--ui-text-muted);padding-left:.75rem}.cms-btn{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-weight:500;gap:.375rem;justify-content:space-between;line-height:1;min-height:var(--cms-btn-size);transition:background-color .15s ease,border-color .15s ease,color .15s ease,opacity .15s ease;white-space:nowrap}.cms-btn:disabled,.cms-btn[aria-disabled=true]{opacity:.5;pointer-events:none}.cms-btn.is-block{width:100%}.cms-btn.is-icon{justify-content:center;padding-inline:0;width:var(--cms-btn-size)}.cms-btn-xs{--cms-btn-size:1.75rem;font-size:.875rem;padding:.25rem .75rem}.cms-btn-sm{--cms-btn-size:2rem;font-size:.875rem;padding:.25rem .875rem}.cms-btn-md{--cms-btn-size:2.25rem;font-size:.875rem;padding:.25rem 1.125rem}.cms-btn-lg{--cms-btn-size:2.625rem;font-size:.9375rem;padding:.375rem 1.375rem}.cms-btn-primary.cms-btn-solid{background:var(--ui-primary);color:var(--ui-text-inverted)}.cms-btn-primary.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-primary) 88%,#000)}.cms-btn-primary.cms-btn-subtle{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-primary.cms-btn-subtle:hover{background:#e0e0e0}.cms-btn-primary.cms-btn-soft{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-neutral.cms-btn-solid{background:var(--ui-bg-inverted);color:var(--ui-text-inverted)}.cms-btn-neutral.cms-btn-subtle{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-subtle:hover{background:var(--ui-bg-accented)}.cms-btn-neutral.cms-btn-soft{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-ghost{color:var(--ui-text-toned)}.cms-btn-neutral.cms-btn-ghost:hover{background:rgba(38,33,25,.06);color:var(--ui-text-highlighted)}.cms-btn-error.cms-btn-solid{background:var(--ui-error);color:#fff}.cms-btn-error.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-error) 88%,#000)}.cms-btn-error.cms-btn-subtle{background:color-mix(in oklab,var(--ui-error) 12%,transparent);color:var(--ui-error)}.cms-btn-error.cms-btn-ghost{color:var(--ui-error)}.cms-btn-error.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-error) 10%,transparent)}.cms-btn-success.cms-btn-solid{background:var(--cms-fern);color:var(--ui-text-inverted)}.cms-form{gap:1.25rem}.cms-form,.cms-form-field{display:flex;flex-direction:column}.cms-form-field{gap:.375rem}.cms-form-label-row{align-items:center;display:flex;gap:.75rem;justify-content:space-between;min-height:1.75rem}.cms-form-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:500}.cms-form-error,.cms-form-required{color:var(--ui-error)}.cms-form-error{font-size:.875rem}.cms-form-actions{align-items:center;display:flex;gap:.75rem;justify-content:flex-end;padding-top:.25rem}.cms-field{background:var(--ui-bg);border:1px solid var(--cms-line-strong);border-radius:var(--ui-radius);color:var(--ui-text-highlighted);line-height:1.25;transition:border-color .15s ease,box-shadow .15s ease;width:100%}textarea.cms-field{line-height:1.5;min-height:0;padding-bottom:.625rem;padding-top:.625rem;resize:vertical}.cms-field::-moz-placeholder{color:var(--ui-text-dimmed)}.cms-field::placeholder{color:var(--ui-text-dimmed)}.cms-field:focus,.cms-field:focus-within{border-color:var(--ui-primary);box-shadow:0 0 0 3px color-mix(in oklab,var(--ui-primary) 18%,transparent);outline:none}.cms-field.is-error{border-color:var(--ui-error)}.cms-field-sm{font-size:.875rem;min-height:2rem;padding:.25rem .625rem}.cms-field-md{font-size:.875rem;min-height:2.25rem;padding:.25rem .75rem}.cms-field-lg{font-size:.9375rem;min-height:2.625rem;padding:.375rem .875rem}.cms-select-chevron{align-items:center;cursor:pointer;display:inline-flex;transition:transform .18s ease,color .15s ease}.cms-select-chevron:hover{color:var(--ui-text-muted)}.cms-select-chevron.is-open{transform:rotate(180deg)}.cms-locale-switch{align-items:center;background:var(--cms-paper);border:1px solid var(--cms-line);border-radius:999px;display:inline-flex;gap:2px;padding:2px}.cms-locale-option{border-radius:999px;color:var(--ui-text-muted);cursor:pointer;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.6875rem;letter-spacing:.06em;line-height:1.4;padding:.125rem .5rem;text-transform:uppercase;transition:background-color .15s ease,color .15s ease}.cms-locale-option:hover:not(.is-active){color:var(--ui-text-highlighted)}.cms-locale-option.is-empty{color:var(--ui-text-dimmed)}.cms-locale-option.is-active{background:var(--ui-bg);box-shadow:0 1px 2px rgba(38,33,25,.1);color:var(--ui-text-highlighted)}.cms-input-wrap{align-items:center;display:flex;position:relative;width:100%}.cms-input-wrap .cms-field{width:100%}.cms-input-lead,.cms-input-trail{align-items:center;color:var(--ui-text-dimmed);display:flex;position:absolute}.cms-input-lead{left:.625rem}.cms-input-trail{right:.375rem}.cms-input-wrap.has-lead .cms-field{padding-left:2rem}.cms-switch{align-items:center;background:var(--cms-line-strong);border-radius:9999px;display:inline-flex;flex-shrink:0;height:1.25rem;position:relative;transition:background-color .15s ease;width:2.25rem}.cms-switch.is-on{background:var(--ui-primary)}.cms-switch:after{background:#fff;border-radius:9999px;content:"";height:.875rem;left:.1875rem;position:absolute;transition:transform .15s ease;width:.875rem}.cms-switch.is-on:after{transform:translateX(1rem)}.cms-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(38,33,25,.32);display:flex;inset:0;justify-content:center;overflow-y:auto;padding:2rem 1rem;position:fixed;z-index:60}.cms-modal{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft);max-width:32rem;width:100%}.cms-modal.is-sm{max-width:28rem}.cms-modal.is-lg{max-width:48rem}.cms-modal-header{padding:1.25rem 1.5rem 0}.cms-modal-body{padding:1.25rem 1.5rem 1.5rem}.cms-overlay-drawer{align-items:stretch;justify-content:flex-end;overflow:hidden;padding:0;z-index:55}.cms-drawer{background:var(--ui-bg);border-left:1px solid var(--cms-line);box-shadow:-18px 0 48px rgba(38,33,25,.12);display:flex;flex-direction:column;height:100%;max-width:46rem;width:100%}.cms-drawer-header{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-body{flex:1;min-height:0;overflow-y:auto;padding:1.75rem}.cms-drawer-footer{align-items:center;border-top:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-enter-active,.cms-drawer-leave-active{transition:opacity .3s ease}.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active .cms-drawer{transition:transform .36s cubic-bezier(.32,.72,0,1);will-change:transform}.cms-drawer-enter-from,.cms-drawer-leave-to{opacity:0}.cms-drawer-enter-from .cms-drawer,.cms-drawer-leave-to .cms-drawer{transform:translateX(100%)}@media (prefers-reduced-motion:reduce){.cms-drawer-enter-active,.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active,.cms-drawer-leave-active .cms-drawer{transition-duration:.01ms}}.cms-menu{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;min-width:12rem;padding:.25rem;position:absolute;z-index:50}.cms-menu.align-end{right:0}.cms-menu-item{align-items:center;border-radius:.5rem;color:var(--ui-text);cursor:pointer;display:flex;font-size:.875rem;gap:.5rem;padding:.4rem .6rem;text-align:left;width:100%}.cms-menu-item:hover{background:var(--ui-bg-elevated)}.cms-menu-check{color:var(--ui-primary);margin-left:auto}.cms-popover{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;position:absolute;z-index:50}.cms-selectmenu-input{background:transparent;color:var(--ui-text-highlighted);flex:1;min-width:4rem;outline:none}.cms-selectmenu-affix{color:var(--ui-text-dimmed);flex-shrink:0}.cms-selectmenu-panel{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);left:0;margin-top:.375rem;max-height:15rem;overflow-y:auto;padding:.25rem;position:absolute;right:0;z-index:50}.cms-tag{align-items:center;background:var(--ui-bg-elevated);border-radius:9999px;display:inline-flex;font-size:.875rem;gap:.25rem;padding:.125rem .5rem}.cms-media-gallery{display:flex;flex-direction:column;gap:1.25rem}.cms-media-filters{align-items:center;display:flex;flex-wrap:wrap;gap:.25rem}.cms-media-filter-separator{background:var(--cms-line);flex:none;height:1rem;margin-inline:.25rem;width:1px}.cms-media-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-media-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-media-grid:not(.is-compact){grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-media-tile{overflow:hidden;text-align:left}.cms-media-tile.is-selectable:hover{border-color:var(--cms-line-strong)}.cms-media-preview{align-items:center;aspect-ratio:1;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative}.cms-media-preview>img,.cms-media-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-media-actions{display:flex;gap:.25rem;opacity:0;position:absolute;right:.5rem;top:.5rem;transition:opacity .15s ease}.cms-media-actions:focus-within,.cms-media-tile:hover .cms-media-actions{opacity:1}.cms-media-info{background:linear-gradient(0deg,rgba(0,0,0,.7),transparent);bottom:0;color:#fff;font-family:Fragment Mono,ui-monospace,monospace;font-size:.75rem;left:0;letter-spacing:.1em;line-height:1.4;opacity:0;padding:1.5rem .75rem .5rem;pointer-events:none;position:absolute;right:0;text-transform:uppercase;transition:opacity .15s ease}.cms-media-tile:focus-visible .cms-media-info,.cms-media-tile:hover .cms-media-info{opacity:1}.cms-media-meta{display:flex;flex-direction:column;gap:.25rem;padding:.625rem .875rem}.cms-media-name{font-size:.875rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-preview-large{background:var(--cms-field);max-height:14rem;-o-object-fit:cover;object-fit:cover;width:100%}.cms-media-bar{align-items:center;display:flex;gap:.625rem;padding:.75rem 1rem}.cms-media-bar-icon{color:var(--ui-text-dimmed);flex-shrink:0}.cms-media-bar-name{flex:1;font-size:.875rem;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-thumb{background:var(--cms-field);border-radius:.375rem;height:2.25rem;-o-object-fit:cover;object-fit:cover;width:2.25rem}.cms-media-thumb.is-fallback{align-items:center;color:var(--ui-text-dimmed);display:flex;justify-content:center}.cms-media-thumb-empty{color:var(--ui-text-dimmed)}.cms-table-shell{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.cms-table-scroll{flex:1;min-height:0;overflow:auto}.cms-table{border-collapse:collapse;min-width:-moz-max-content;min-width:max-content;text-align:left;width:100%}.cms-table td,.cms-table th{white-space:nowrap}.cms-table thead{background:var(--cms-surface);position:sticky;top:0;z-index:10}.cms-table th{box-shadow:inset 0 -1px 0 var(--cms-line);color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;font-weight:400;letter-spacing:.1em;line-height:1.4;padding:.75rem 1.25rem;text-transform:uppercase}.cms-table td{color:var(--ui-text);font-size:.875rem;padding:.875rem 1.25rem}.cms-table tbody tr{border-bottom:1px solid var(--cms-line)}.cms-table tbody tr:last-child{border-bottom:0}.cms-table tbody tr:hover{background:color-mix(in oklab,var(--cms-paper) 50%,transparent)}.cms-table-cell-actions{display:flex;justify-content:flex-end}.cms-table th[draggable=true]{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.cms-table th[draggable=true]:active{cursor:grabbing}.cms-table th.is-dragging{opacity:.4}.cms-table th.is-drop-target{background:var(--cms-paper);box-shadow:inset 0 -1px 0 var(--cms-line),inset 0 0 0 2px var(--cms-line-strong)}.cms-alert{border-radius:.75rem;font-size:.875rem;padding:.75rem 1rem}.cms-alert-error{background:color-mix(in oklab,var(--ui-error) 10%,transparent);color:var(--ui-error)}.cms-toaster{bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:calc(100vw - 2rem);position:fixed;right:1rem;width:20rem;z-index:80}.cms-toast{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);padding:.75rem .875rem}.cms-toast-row{align-items:flex-start;display:flex;gap:.75rem}.cms-toast-title{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-toast-description{color:var(--ui-text-muted);font-size:.875rem;margin-top:.125rem}.cms-toast-dismiss{color:var(--ui-text-dimmed);flex-shrink:0}.cms-toast-dismiss:hover{color:var(--ui-text)}.cms-toast.is-success{border-left:3px solid var(--cms-fern)}.cms-toast.is-error{border-left:3px solid var(--ui-error)}.cms-pagination{align-items:center;display:flex;gap:.25rem;justify-content:center}.cms-page-btn{border-radius:.5rem;color:var(--ui-text-toned);font-size:.875rem;height:2rem;min-width:2rem;padding:0 .5rem}.cms-page-btn:hover:not(:disabled){background:var(--ui-bg-elevated)}.cms-page-btn.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-page-btn:disabled{opacity:.4}
|
|
1
|
+
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&family=Hanken+Grotesk:ital,wght@0,300..800;1,300..800&family=Fragment+Mono:ital@0;1&display=swap");@layer theme, base, components, utilities;@import "tailwindcss/theme.css" layer(theme) theme(inline);.cms-scope{@import "tailwindcss/preflight.css" layer(base);@import "tailwindcss/utilities.css" layer(utilities) source(none)}@source "../app";.cms-scope,body:has(.cms-scope){--cms-paper:#f5f5f5;--cms-surface:#fff;--cms-field:#f0f0f0;--cms-ink:#171717;--cms-line:#e5e5e5;--cms-line-strong:#d4d4d4;--cms-fern:#171717;--cms-sage:#ebebeb;--cms-sage-ink:#171717;--cms-shadow-soft:none;color-scheme:light;--ui-text-dimmed:#a3a3a3;--ui-text-muted:#737373;--ui-text-toned:#525252;--ui-text:#404040;--ui-text-highlighted:var(--cms-ink);--ui-text-inverted:#fff;--ui-bg:var(--cms-surface);--ui-bg-muted:var(--cms-paper);--ui-bg-elevated:var(--cms-field);--ui-bg-accented:#e0e0e0;--ui-bg-inverted:var(--cms-ink);--ui-border:var(--cms-line);--ui-border-muted:var(--cms-line);--ui-border-accented:var(--cms-line-strong);--ui-border-inverted:var(--cms-ink);--ui-primary:var(--cms-ink);--ui-error:#dc2626;--ui-radius:0.4rem;color:var(--ui-text);font-family:Hanken Grotesk,ui-sans-serif,system-ui,sans-serif;margin:0;padding:0;-webkit-font-smoothing:antialiased}body:has(.cms-scope) ::-moz-selection{background:var(--cms-sage);color:var(--cms-sage-ink)}body:has(.cms-scope) ::selection{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-display,.cms-title{font-family:Poppins,ui-sans-serif,system-ui,sans-serif;letter-spacing:-.02em}.cms-title{color:var(--ui-text-highlighted);font-weight:500}.cms-title-sm{font-size:1.25rem;line-height:1.4}.cms-title-md{font-size:2rem;line-height:1}.cms-title-lg{font-size:2.25rem;line-height:1.2}.cms-subtitle{color:var(--ui-text-muted);font-size:.875rem}.cms-accent{color:var(--cms-fern)}.cms-kicker{color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;letter-spacing:.16em;line-height:1.4;text-transform:uppercase}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]){@apply cursor-pointer transition-all duration-200 select-none}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]):active{@apply transition-none;scale:.99}.cms-canvas{background-color:var(--cms-paper)}.cms-grain:before{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='a'%3E%3CfeTurbulence baseFrequency='.8' numOctaves='2' stitchTiles='stitch' type='fractalNoise'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");content:"";inset:0;mix-blend-mode:multiply;opacity:.035;pointer-events:none;position:fixed;z-index:50}.cms-card{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft)}.cms-panel{padding:1.75rem}.cms-shell{display:flex;min-height:100vh}.cms-sidebar{border-right:1px solid var(--cms-line);display:flex;flex-direction:column;flex-shrink:0;gap:1.75rem;height:100vh;padding:1.75rem 1rem;position:sticky;top:0;width:16rem}.cms-sidebar-brand{color:var(--ui-text-highlighted);font-family:Poppins,ui-sans-serif,system-ui,sans-serif;font-size:1.375rem;font-weight:600;letter-spacing:-.02em;padding-inline:.75rem}.cms-sidebar-nav{display:flex;flex:1;flex-direction:column;gap:1.5rem;overflow-y:auto}.cms-sidebar-group{display:flex;flex-direction:column;gap:.25rem}.cms-sidebar-group>.cms-kicker{margin-bottom:.5rem;padding-inline:.75rem}.cms-sidebar-footer{border-top:1px solid var(--cms-line);display:flex;flex-direction:column;gap:.75rem;padding:1.25rem .75rem 0}.cms-main{flex:1;height:100vh;min-width:0;overflow-y:auto;padding:2.5rem}.cms-main-inner{margin-inline:auto;max-width:64rem;min-height:100%;width:100%}.cms-main-inner,.cms-page{display:flex;flex-direction:column}.cms-page{gap:1.75rem}.cms-page.is-fill{flex:1;min-height:0}.cms-page-header{align-items:flex-end;display:flex;gap:1rem;justify-content:space-between}.cms-page-heading{display:flex;flex-direction:column;gap:.5rem}.cms-toolbar{align-items:center;display:flex;gap:1.5rem}.cms-toolbar-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-actions{align-items:center;display:flex;gap:.75rem}.cms-actions.is-end{justify-content:flex-end}.cms-spinner{color:var(--ui-text-dimmed);flex:1;justify-content:center;padding-block:2.5rem}.cms-empty,.cms-spinner{align-items:center;display:flex}.cms-empty{background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;flex-direction:column;gap:.5rem;padding:3.5rem;text-align:center}.cms-empty.is-fill{flex:1;justify-content:center}.cms-empty-icon{color:var(--cms-fern);margin-bottom:.25rem}.cms-empty>.cms-btn{margin-top:.75rem}.cms-auth{align-items:center;display:flex;justify-content:center;min-height:100vh;padding:1.5rem}.cms-auth-panel{gap:2rem;max-width:24rem;width:100%}.cms-auth-header,.cms-auth-panel{display:flex;flex-direction:column}.cms-auth-header{align-items:center;gap:.75rem;text-align:center}.cms-auth-card{padding:1.75rem}.cms-navlink{align-items:center;border-radius:.75rem;color:var(--ui-text-toned);display:flex;font-size:.875rem;font-weight:500;gap:.625rem;padding:.5rem .75rem;transition:background-color .15s ease,color .15s ease}.cms-navlink:hover{background:rgba(38,33,25,.05);color:var(--ui-text-highlighted)}.cms-navlink.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-navlink-icon{color:var(--ui-text-dimmed)}.cms-navlink.is-active .cms-navlink-icon{color:var(--cms-fern)}.cms-badge{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;letter-spacing:.1em;padding:.1875rem .5rem;pointer-events:none;text-transform:uppercase;transition:background-color .15s ease,border-color .15s ease;white-space:nowrap}.cms-badge.is-published{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-badge.is-muted{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-muted)}.cms-badge.is-draft{background:transparent;border:1px dashed var(--cms-line-strong);color:var(--ui-text-muted)}.cms-dropzone{align-items:center;background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;color:var(--ui-text-muted);cursor:pointer;display:flex;flex-direction:column;gap:.5rem;justify-content:center;padding:2rem 1.5rem;transition:border-color .15s ease,background-color .15s ease,color .15s ease;width:100%}.cms-dropzone.is-over,.cms-dropzone:hover{background:var(--cms-sage);border-color:var(--cms-fern);color:var(--cms-sage-ink)}.cms-dropzone.is-dense{border-radius:.75rem;flex-direction:row;gap:.625rem;padding:.875rem 1.25rem}.cms-dropzone.is-busy{opacity:.7;pointer-events:none}.cms-pill{align-items:center;background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;flex:none;font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;gap:.35rem;letter-spacing:.12em;padding:.3rem .8rem;transition:background-color .15s ease,color .15s ease;white-space:nowrap}.cms-pill:hover{background:rgba(38,33,25,.05);color:var(--ui-text)}.cms-pill.is-active{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-pill.is-empty{border-style:dashed}.cms-pill-group{align-items:center;display:inline-flex;gap:.125rem}.cms-pill-group .cms-pill{padding-right:.6rem}.cms-pill-discard{align-items:center;border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;height:1.25rem;justify-content:center;transition:color .15s ease;width:1.25rem}.cms-pill-discard:hover{color:var(--ui-error)}.cms-folder-picker{align-items:center;display:flex;flex-wrap:wrap;gap:.375rem}.cms-folder-picker-input{width:11rem}.cms-form-hint{color:var(--ui-text-dimmed);font-size:.8125rem}.cms-dropzone-hint{align-items:center;display:inline-flex;font-family:Fragment Mono,ui-monospace,monospace;font-size:.75rem;gap:.3rem;letter-spacing:.08em;opacity:.7;text-transform:uppercase}@keyframes cms-rise{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}.cms-rise{animation:cms-rise .55s cubic-bezier(.22,1,.36,1) both}.cms-rise.is-delayed{animation-delay:90ms}@media (prefers-reduced-motion:reduce){.cms-rise{animation:none}}.cms-richtext{border:1px solid var(--cms-line);border-radius:.75rem;overflow:hidden}.cms-richtext-toolbar{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;flex-wrap:wrap;gap:2px;padding:4px}.cms-richtext-body{font-size:.875rem;min-height:9rem;outline:none;padding:.75rem 1rem}.cms-richtext-body h2{font-size:1.25rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body h3{font-size:1.1rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body p{margin:.35em 0}.cms-richtext-body ul{list-style:disc;padding-left:1.25rem}.cms-richtext-body ol{list-style:decimal;padding-left:1.25rem}.cms-richtext-body blockquote{border-left:3px solid var(--cms-line);color:var(--ui-text-muted);padding-left:.75rem}.cms-btn{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-weight:500;gap:.375rem;justify-content:space-between;line-height:1;min-height:var(--cms-btn-size);transition:background-color .15s ease,border-color .15s ease,color .15s ease,opacity .15s ease;white-space:nowrap}.cms-btn:disabled,.cms-btn[aria-disabled=true]{opacity:.5;pointer-events:none}.cms-btn.is-block{width:100%}.cms-btn.is-icon{justify-content:center;padding-inline:0;width:var(--cms-btn-size)}.cms-btn-xs{--cms-btn-size:1.75rem;font-size:.875rem;padding:.25rem .75rem}.cms-btn-sm{--cms-btn-size:2rem;font-size:.875rem;padding:.25rem .875rem}.cms-btn-md{--cms-btn-size:2.25rem;font-size:.875rem;padding:.25rem 1.125rem}.cms-btn-lg{--cms-btn-size:2.625rem;font-size:.9375rem;padding:.375rem 1.375rem}.cms-btn-primary.cms-btn-solid{background:var(--ui-primary);color:var(--ui-text-inverted)}.cms-btn-primary.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-primary) 88%,#000)}.cms-btn-primary.cms-btn-subtle{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-primary.cms-btn-subtle:hover{background:#e0e0e0}.cms-btn-primary.cms-btn-soft{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-neutral.cms-btn-solid{background:var(--ui-bg-inverted);color:var(--ui-text-inverted)}.cms-btn-neutral.cms-btn-subtle{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-subtle:hover{background:var(--ui-bg-accented)}.cms-btn-neutral.cms-btn-soft{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-ghost{color:var(--ui-text-toned)}.cms-btn-neutral.cms-btn-ghost:hover{background:rgba(38,33,25,.06);color:var(--ui-text-highlighted)}.cms-btn-error.cms-btn-solid{background:var(--ui-error);color:#fff}.cms-btn-error.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-error) 88%,#000)}.cms-btn-error.cms-btn-subtle{background:color-mix(in oklab,var(--ui-error) 12%,transparent);color:var(--ui-error)}.cms-btn-error.cms-btn-ghost{color:var(--ui-error)}.cms-btn-error.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-error) 10%,transparent)}.cms-btn-success.cms-btn-solid{background:var(--cms-fern);color:var(--ui-text-inverted)}.cms-form{gap:1.25rem}.cms-form,.cms-form-field{display:flex;flex-direction:column}.cms-form-field{gap:.375rem}.cms-form-label-row{align-items:center;display:flex;gap:.75rem;justify-content:space-between;min-height:1.75rem}.cms-form-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:500}.cms-form-error,.cms-form-required{color:var(--ui-error)}.cms-form-error{font-size:.875rem}.cms-form-actions{align-items:center;display:flex;gap:.75rem;justify-content:flex-end;padding-top:.25rem}.cms-field{background:var(--ui-bg);border:1px solid var(--cms-line-strong);border-radius:var(--ui-radius);color:var(--ui-text-highlighted);line-height:1.25;transition:border-color .15s ease,box-shadow .15s ease;width:100%}textarea.cms-field{line-height:1.5;min-height:0;padding-bottom:.625rem;padding-top:.625rem;resize:vertical}.cms-field::-moz-placeholder{color:var(--ui-text-dimmed)}.cms-field::placeholder{color:var(--ui-text-dimmed)}.cms-field:focus,.cms-field:focus-within{border-color:var(--ui-primary);box-shadow:0 0 0 3px color-mix(in oklab,var(--ui-primary) 18%,transparent);outline:none}.cms-field.is-error{border-color:var(--ui-error)}.cms-field-sm{font-size:.875rem;min-height:2rem;padding:.25rem .625rem}.cms-field-md{font-size:.875rem;min-height:2.25rem;padding:.25rem .75rem}.cms-field-lg{font-size:.9375rem;min-height:2.625rem;padding:.375rem .875rem}.cms-select-chevron{align-items:center;cursor:pointer;display:inline-flex;transition:transform .18s ease,color .15s ease}.cms-select-chevron:hover{color:var(--ui-text-muted)}.cms-select-chevron.is-open{transform:rotate(180deg)}.cms-locale-switch{align-items:center;background:var(--cms-paper);border:1px solid var(--cms-line);border-radius:999px;display:inline-flex;gap:2px;padding:2px}.cms-locale-option{border-radius:999px;color:var(--ui-text-muted);cursor:pointer;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.6875rem;letter-spacing:.06em;line-height:1.4;padding:.125rem .5rem;text-transform:uppercase;transition:background-color .15s ease,color .15s ease}.cms-locale-option:hover:not(.is-active){color:var(--ui-text-highlighted)}.cms-locale-option.is-empty{color:var(--ui-text-dimmed)}.cms-locale-option.is-active{background:var(--ui-bg);box-shadow:0 1px 2px rgba(38,33,25,.1);color:var(--ui-text-highlighted)}.cms-input-wrap{align-items:center;display:flex;position:relative;width:100%}.cms-input-wrap .cms-field{width:100%}.cms-input-lead,.cms-input-trail{align-items:center;color:var(--ui-text-dimmed);display:flex;position:absolute}.cms-input-lead{left:.625rem}.cms-input-trail{right:.375rem}.cms-input-wrap.has-lead .cms-field{padding-left:2rem}.cms-switch{align-items:center;background:var(--cms-line-strong);border-radius:9999px;display:inline-flex;flex-shrink:0;height:1.25rem;position:relative;transition:background-color .15s ease;width:2.25rem}.cms-switch.is-on{background:var(--ui-primary)}.cms-switch:after{background:#fff;border-radius:9999px;content:"";height:.875rem;left:.1875rem;position:absolute;transition:transform .15s ease;width:.875rem}.cms-switch.is-on:after{transform:translateX(1rem)}.cms-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(38,33,25,.32);display:flex;inset:0;justify-content:center;overflow-y:auto;padding:2rem 1rem;position:fixed;z-index:60}.cms-modal{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft);display:flex;flex-direction:column;max-height:calc(100dvh - 4rem);max-width:32rem;width:100%}.cms-modal.is-sm{max-width:28rem}.cms-modal.is-lg{max-width:48rem}.cms-modal-header{padding:1.25rem 1.5rem 0}.cms-modal-body{flex:1;min-height:0;overflow-y:auto;padding:1.25rem 1.5rem 1.5rem}.cms-overlay-drawer{align-items:stretch;justify-content:flex-end;overflow:hidden;padding:0;z-index:55}.cms-drawer{background:var(--ui-bg);border-left:1px solid var(--cms-line);box-shadow:-18px 0 48px rgba(38,33,25,.12);display:flex;flex-direction:column;height:100%;max-width:46rem;width:100%}.cms-drawer-header{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-body{flex:1;min-height:0;overflow-y:auto;padding:1.75rem}.cms-drawer-footer{align-items:center;border-top:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-enter-active,.cms-drawer-leave-active{transition:opacity .3s ease}.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active .cms-drawer{transition:transform .36s cubic-bezier(.32,.72,0,1);will-change:transform}.cms-drawer-enter-from,.cms-drawer-leave-to{opacity:0}.cms-drawer-enter-from .cms-drawer,.cms-drawer-leave-to .cms-drawer{transform:translateX(100%)}@media (prefers-reduced-motion:reduce){.cms-drawer-enter-active,.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active,.cms-drawer-leave-active .cms-drawer{transition-duration:.01ms}}.cms-menu{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;min-width:12rem;padding:.25rem;position:absolute;z-index:50}.cms-menu.align-end{right:0}.cms-menu-item{align-items:center;border-radius:.5rem;color:var(--ui-text);cursor:pointer;display:flex;font-size:.875rem;gap:.5rem;padding:.4rem .6rem;text-align:left;width:100%}.cms-menu-item:hover{background:var(--ui-bg-elevated)}.cms-menu-check{color:var(--ui-primary);margin-left:auto}.cms-popover{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;position:absolute;z-index:50}.cms-selectmenu-input{background:transparent;color:var(--ui-text-highlighted);flex:1;min-width:4rem;outline:none}.cms-selectmenu-affix{color:var(--ui-text-dimmed);flex-shrink:0}.cms-selectmenu-panel{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);left:0;margin-top:.375rem;max-height:15rem;overflow-y:auto;padding:.25rem;position:absolute;right:0;z-index:50}.cms-tag{align-items:center;background:var(--ui-bg-elevated);border-radius:9999px;display:inline-flex;font-size:.875rem;gap:.25rem;padding:.125rem .5rem}.cms-media-gallery{display:flex;flex-direction:column;gap:1.25rem}.cms-media-filters{align-items:center;display:flex;flex-wrap:wrap;gap:.25rem}.cms-media-filters-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-media-filter-separator{background:var(--cms-line);flex:none;height:1rem;margin-inline:.25rem;width:1px}.cms-media-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-media-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-media-grid:not(.is-compact){grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-media-tile{overflow:hidden;text-align:left}.cms-media-tile.is-selectable{cursor:pointer}.cms-media-tile.is-selectable:hover{border-color:var(--cms-line-strong)}.cms-media-preview{align-items:center;aspect-ratio:1;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative}.cms-media-preview>img,.cms-media-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-media-actions{display:flex;gap:.25rem;opacity:0;position:absolute;right:.5rem;top:.5rem;transition:opacity .15s ease}.cms-media-actions:focus-within,.cms-media-tile:hover .cms-media-actions{opacity:1}.cms-media-info{background:linear-gradient(0deg,rgba(0,0,0,.7),transparent);bottom:0;color:#fff;font-family:Fragment Mono,ui-monospace,monospace;font-size:.75rem;left:0;letter-spacing:.1em;line-height:1.4;opacity:0;padding:1.5rem .75rem .5rem;pointer-events:none;position:absolute;right:0;text-transform:uppercase;transition:opacity .15s ease}.cms-media-tile:focus-visible .cms-media-info,.cms-media-tile:hover .cms-media-info{opacity:1}.cms-media-meta{display:flex;flex-direction:column;gap:.25rem;padding:.625rem .875rem}.cms-media-name{font-size:.875rem;font-weight:500}.cms-media-folder,.cms-media-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-folder{align-items:center;color:var(--ui-text-dimmed);display:inline-flex;font-size:.75rem;gap:.25rem}.cms-media-field{overflow:hidden}.cms-media-field-preview{align-items:center;background:repeating-conic-gradient(from 0deg,rgba(38,33,25,.04) 0 25%,transparent 0 50%) 50% /16px 16px;color:var(--ui-text-dimmed);display:flex;justify-content:center;max-height:16rem;min-height:8rem;padding:.75rem}.cms-media-field-preview>img,.cms-media-field-preview>video{max-height:14.5rem;max-width:100%;-o-object-fit:contain;object-fit:contain}.cms-media-field-bar{align-items:center;background:var(--ui-bg);border-top:1px solid var(--cms-line);display:flex;gap:.5rem;padding:.625rem .75rem}.cms-media-bar-icon{color:var(--ui-text-dimmed);flex-shrink:0}.cms-media-bar-name{flex:1;font-size:.875rem;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-thumb{background:var(--cms-field);border-radius:.375rem;height:2.25rem;-o-object-fit:cover;object-fit:cover;width:2.25rem}.cms-media-thumb.is-fallback{align-items:center;color:var(--ui-text-dimmed);display:flex;justify-content:center}.cms-media-thumb-empty{color:var(--ui-text-dimmed)}.cms-table-shell{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.cms-table-scroll{flex:1;min-height:0;overflow:auto}.cms-table{border-collapse:collapse;min-width:-moz-max-content;min-width:max-content;text-align:left;width:100%}.cms-table td,.cms-table th{white-space:nowrap}.cms-table thead{background:var(--cms-surface);position:sticky;top:0;z-index:10}.cms-table th{box-shadow:inset 0 -1px 0 var(--cms-line);color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;font-weight:400;letter-spacing:.1em;line-height:1.4;padding:.75rem 1.25rem;text-transform:uppercase}.cms-table td{color:var(--ui-text);font-size:.875rem;padding:.875rem 1.25rem}.cms-table tbody tr{border-bottom:1px solid var(--cms-line)}.cms-table tbody tr:last-child{border-bottom:0}.cms-table tbody tr:hover{background:color-mix(in oklab,var(--cms-paper) 50%,transparent)}.cms-table-cell-actions{display:flex;justify-content:flex-end}.cms-table th[draggable=true]{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.cms-table th[draggable=true]:active{cursor:grabbing}.cms-table th.is-dragging{opacity:.4}.cms-table th.is-drop-target{background:var(--cms-paper);box-shadow:inset 0 -1px 0 var(--cms-line),inset 0 0 0 2px var(--cms-line-strong)}.cms-th-label{align-items:center;display:inline-flex;gap:.35rem}.cms-table th.is-sortable{cursor:pointer}.cms-table th.is-sortable:hover,.cms-table th.is-sorted{color:var(--ui-text)}.cms-th-sort{flex:none;opacity:0;transition:opacity .15s ease}.cms-table th.is-sortable:hover .cms-th-sort,.cms-table th.is-sorted .cms-th-sort{opacity:1}.cms-alert{border-radius:.75rem;font-size:.875rem;padding:.75rem 1rem}.cms-alert-error{background:color-mix(in oklab,var(--ui-error) 10%,transparent);color:var(--ui-error)}.cms-toaster{bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:calc(100vw - 2rem);position:fixed;right:1rem;width:20rem;z-index:80}.cms-toast{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);padding:.75rem .875rem}.cms-toast-row{align-items:flex-start;display:flex;gap:.75rem}.cms-toast-title{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-toast-description{color:var(--ui-text-muted);font-size:.875rem;margin-top:.125rem}.cms-toast-dismiss{color:var(--ui-text-dimmed);flex-shrink:0}.cms-toast-dismiss:hover{color:var(--ui-text)}.cms-toast.is-success{border-left:3px solid var(--cms-fern)}.cms-toast.is-error{border-left:3px solid var(--ui-error)}.cms-pagination{align-items:center;display:flex;gap:.25rem;justify-content:center}.cms-page-btn{border-radius:.5rem;color:var(--ui-text-toned);font-size:.875rem;height:2rem;min-width:2rem;padding:0 .5rem}.cms-page-btn:hover:not(:disabled){background:var(--ui-bg-elevated)}.cms-page-btn.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-page-btn:disabled{opacity:.4}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { count, desc, sql } from "drizzle-orm";
|
|
1
|
+
import { asc, count, desc, sql } from "drizzle-orm";
|
|
2
2
|
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
@@ -9,6 +9,8 @@ const querySchema = z.object({
|
|
|
9
9
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
10
10
|
offset: z.coerce.number().int().min(0).default(0),
|
|
11
11
|
search: z.string().trim().max(200).optional(),
|
|
12
|
+
sort: z.string().trim().max(64).optional(),
|
|
13
|
+
order: z.enum(["asc", "desc"]).default("desc"),
|
|
12
14
|
light: z.stringbool().default(false)
|
|
13
15
|
});
|
|
14
16
|
function likePattern(term) {
|
|
@@ -23,7 +25,10 @@ export default defineEventHandler(async (event) => {
|
|
|
23
25
|
await attachManyToMany(db, name, entry, rows);
|
|
24
26
|
return rows[0] ?? null;
|
|
25
27
|
}
|
|
26
|
-
const { limit, offset, search, light } = await getValidatedQuery(
|
|
28
|
+
const { limit, offset, search, sort, order, light } = await getValidatedQuery(
|
|
29
|
+
event,
|
|
30
|
+
querySchema.parse
|
|
31
|
+
);
|
|
27
32
|
const columns = tableColumns(table);
|
|
28
33
|
const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
|
|
29
34
|
let where;
|
|
@@ -36,9 +41,14 @@ export default defineEventHandler(async (event) => {
|
|
|
36
41
|
...entry.titleField && titleColumn ? [[entry.titleField, titleColumn]] : [],
|
|
37
42
|
...entry.drafts && Object.hasOwn(columns, "status") ? [["status", columns.status]] : []
|
|
38
43
|
]) : void 0;
|
|
39
|
-
const
|
|
44
|
+
const sortColumn = sort && Object.hasOwn(columns, sort) ? columns[sort] : void 0;
|
|
45
|
+
const orderBy = sortColumn ?? columns.createdAt ?? idColumn(table);
|
|
46
|
+
const direction = sortColumn && order === "asc" ? asc : desc;
|
|
47
|
+
const tiebreaker = idColumn(table);
|
|
40
48
|
const base = selection ? db.select(selection).from(table) : db.select().from(table);
|
|
41
|
-
const items = await (where ? base.where(where) : base).orderBy(
|
|
49
|
+
const items = await (where ? base.where(where) : base).orderBy(
|
|
50
|
+
...orderBy === tiebreaker ? [direction(orderBy)] : [direction(orderBy), desc(tiebreaker)]
|
|
51
|
+
).limit(limit).offset(offset);
|
|
42
52
|
const [counted] = await (where ? db.select({ total: count() }).from(table).where(where) : db.select({ total: count() }).from(table));
|
|
43
53
|
if (light) return { items, total: counted?.total ?? 0, relations: {} };
|
|
44
54
|
await attachManyToMany(db, name, entry, items);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { slugify } from "../../shared/index.js";
|
|
4
|
+
import { normalizeMediaFolder, slugify } from "../../shared/index.js";
|
|
5
5
|
import { assertUploadContentType, useMediaStorage } from "../utils/media.js";
|
|
6
6
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
7
|
const MAX_BASE_LENGTH = 80;
|
|
@@ -10,7 +10,8 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
|
10
10
|
const bodySchema = z.object({
|
|
11
11
|
filename: z.string().trim().min(1).max(255),
|
|
12
12
|
contentType: z.string().regex(/^[-\w.+]+\/[-\w.+]+$/, "Invalid content type"),
|
|
13
|
-
size: z.number().int().positive()
|
|
13
|
+
size: z.number().int().positive(),
|
|
14
|
+
folder: z.string().max(255).nullish()
|
|
14
15
|
});
|
|
15
16
|
function slugifyFilename(filename) {
|
|
16
17
|
const dot = filename.lastIndexOf(".");
|
|
@@ -21,7 +22,7 @@ function slugifyFilename(filename) {
|
|
|
21
22
|
export default defineEventHandler(async (event) => {
|
|
22
23
|
await requireAdmin(event);
|
|
23
24
|
const { media, client, bucketUrl, publicUrl } = useMediaStorage(event);
|
|
24
|
-
const { filename, contentType, size } = await readValidatedBody(event, bodySchema.parse);
|
|
25
|
+
const { filename, contentType, size, folder } = await readValidatedBody(event, bodySchema.parse);
|
|
25
26
|
assertUploadContentType(contentType);
|
|
26
27
|
if (size > MAX_FILE_SIZE) {
|
|
27
28
|
throw createError({
|
|
@@ -30,7 +31,8 @@ export default defineEventHandler(async (event) => {
|
|
|
30
31
|
});
|
|
31
32
|
}
|
|
32
33
|
const now = /* @__PURE__ */ new Date();
|
|
33
|
-
const
|
|
34
|
+
const normalizedFolder = normalizeMediaFolder(folder);
|
|
35
|
+
const prefix = normalizedFolder ?? `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
34
36
|
const key = `${prefix}/${randomUUID()}-${slugifyFilename(filename)}`;
|
|
35
37
|
const url = new URL(`${bucketUrl}/${key}`);
|
|
36
38
|
url.searchParams.set("X-Amz-Expires", String(media.presignExpiry));
|
|
@@ -43,6 +45,7 @@ export default defineEventHandler(async (event) => {
|
|
|
43
45
|
);
|
|
44
46
|
return {
|
|
45
47
|
key,
|
|
48
|
+
folder: normalizedFolder,
|
|
46
49
|
uploadUrl: signed.url,
|
|
47
50
|
method: "PUT",
|
|
48
51
|
headers: { "content-type": contentType },
|
|
@@ -2,6 +2,7 @@ import { defineEventHandler, readValidatedBody } from "h3";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { useDb } from "#cms-db";
|
|
4
4
|
import { cms_media } from "#cms-tables";
|
|
5
|
+
import { normalizeMediaFolder } from "../../shared/index.js";
|
|
5
6
|
import { objectKeySchema } from "../../shared/validation.js";
|
|
6
7
|
import {
|
|
7
8
|
assertMediaWritable,
|
|
@@ -32,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
|
|
32
33
|
width: body.width ?? null,
|
|
33
34
|
height: body.height ?? null,
|
|
34
35
|
alt: body.alt ?? null,
|
|
35
|
-
folder: body.folder
|
|
36
|
+
folder: normalizeMediaFolder(body.folder)
|
|
36
37
|
};
|
|
37
38
|
const [row] = await useDb().insert(cms_media).values(values).onConflictDoUpdate({ target: cms_media.key, set: values }).returning();
|
|
38
39
|
return toMediaItem(row, publicUrl);
|
|
@@ -3,6 +3,7 @@ import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
5
|
import { cms_media } from "#cms-tables";
|
|
6
|
+
import { normalizeMediaFolder } from "../../shared/index.js";
|
|
6
7
|
import { assertMediaWritable, toMediaItem, useMediaConfig } from "../utils/media.js";
|
|
7
8
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
8
9
|
const bodySchema = z.object({
|
|
@@ -17,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
|
|
17
18
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
18
19
|
const updates = {};
|
|
19
20
|
if (body.alt !== void 0) updates.alt = body.alt;
|
|
20
|
-
if (body.folder !== void 0) updates.folder = body.folder;
|
|
21
|
+
if (body.folder !== void 0) updates.folder = normalizeMediaFolder(body.folder);
|
|
21
22
|
if (Object.keys(updates).length === 0) {
|
|
22
23
|
throw createError({ statusCode: 400, statusMessage: "No fields to update" });
|
|
23
24
|
}
|
|
@@ -15,6 +15,8 @@ export declare function mediaTypeFor(mime: string | null | undefined, key: strin
|
|
|
15
15
|
export declare function mediaIconFor(type: MediaType): string;
|
|
16
16
|
export declare function mediaPublicUrl(baseUrl: string | null | undefined, key: string): string | null;
|
|
17
17
|
export declare function slugify(value: string): string;
|
|
18
|
+
export declare const MEDIA_FOLDER_MAX_DEPTH = 4;
|
|
19
|
+
export declare function normalizeMediaFolder(value: string | null | undefined): string | null;
|
|
18
20
|
export interface MediaItem {
|
|
19
21
|
id: number;
|
|
20
22
|
key: string;
|
|
@@ -44,6 +44,12 @@ export function mediaPublicUrl(baseUrl, key) {
|
|
|
44
44
|
export function slugify(value) {
|
|
45
45
|
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036F]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
46
46
|
}
|
|
47
|
+
export const MEDIA_FOLDER_MAX_DEPTH = 4;
|
|
48
|
+
export function normalizeMediaFolder(value) {
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
const segments = value.split("/").map(slugify).filter(Boolean).slice(0, MEDIA_FOLDER_MAX_DEPTH);
|
|
51
|
+
return segments.length ? segments.join("/") : null;
|
|
52
|
+
}
|
|
47
53
|
export function isTranslatableField(field) {
|
|
48
54
|
return !!field.translatable && (field.type === "text" || field.type === "richtext");
|
|
49
55
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Edoardo Alberti (https://github.com/xleddyl)",
|