1<template>
2 <Dialog :open="show" size="md" bare @update:open="onOpenChange">
3 <div class="flex flex-col" style="height: min(640px, calc(100vh - 8rem))">
4 <div class="flex shrink-0 items-center justify-between px-4 pt-3">
5 <div class="flex items-center gap-2">
6 <Dialog.Title as-child>
7 <h2 class="text-lg font-medium text-ink-gray-9">
8 {{ __("Pinned categories") }}
9 </h2>
10 </Dialog.Title>
11 <!-- As the settings title shows it. -->
12 <Badge
13 v-if="hasUnsavedChanges"
14 variant="subtle"
15 theme="amber"
16 size="sm"
17 :label="__('Unsaved')"
18 />
19 </div>
20 <Dialog.Close as-child>
21 <Button variant="ghost" :label="__('Close')" icon="lucide-x" />
22 </Dialog.Close>
23 </div>
24
25 <!-- The divider sits under the search, so scrolled rows clip against it -->
26 <div class="shrink-0 border-b border-outline-gray-1 px-4 py-3">
27 <TextInput
28 v-model="query"
29 :placeholder="__('Search categories to pin')"
30 :aria-label="__('Search categories to pin')"
31 >
32 <template #prefix>
33 <span
34 class="lucide-search size-4 text-ink-gray-5"
35 aria-hidden="true"
36 />
37 </template>
38 <template v-if="query" #suffix>
39 <Button
40 variant="ghost"
41 size="xs"
42 icon="lucide-x"
43 :label="__('Clear search')"
44 @click="query = ''"
45 />
46 </template>
47 </TextInput>
48 </div>
49
50 <ScrollArea class="min-h-0 flex-1" viewport-class="px-4 py-3">
51 <div v-if="categories.length" class="space-y-4">
52 <section class="space-y-1.5">
53 <h3 class="text-base font-medium text-ink-gray-8">
54 {{ __("Pinned") }}
55 </h3>
56 <!-- Search leaves the pins alone: it finds categories to pin. -->
57 <Draggable
58 :list="pinnedCategories"
59 item-key="value"
60 :group="GROUP"
61 v-bind="DRAG_OPTIONS"
62 :class="LIST"
63 @change="onPinnedChange"
64 >
65 <template #item="{ element: category }">
66 <div :class="[ROW, 'cursor-grab']">
67 <span
68 class="lucide-grip-vertical size-4 shrink-0 text-ink-gray-4"
69 aria-hidden="true"
70 />
71 <span
72 :class="category.icon"
73 class="size-4 shrink-0 text-ink-gray-6"
74 />
75 <span class="min-w-0 flex-1 truncate text-base">
76 {{ category.label }}
77 </span>
78 <CategoryAccessBadge :access="category.access" />
79 <Button
80 variant="ghost"
81 size="xs"
82 icon="lucide-pin-off"
83 :label="__('Unpin {0}', [category.label])"
84 :tooltip="__('Unpin')"
85 :class="ACTION"
86 @click.stop="moveTo(category.value, 'unpinned')"
87 />
88 </div>
89 </template>
90 <template #footer>
91 <div
92 v-if="!pinnedCategories.length"
93 class="px-3 py-6 text-center text-p-sm text-ink-gray-5"
94 >
95 {{
96 __(
97 "Nothing pinned yet. The home page will list all categories alphabetically."
98 )
99 }}
100 </div>
101 </template>
102 </Draggable>
103 </section>
104
105 <section class="space-y-1.5">
106 <h3 class="text-base font-medium text-ink-gray-8">
107 {{ __("Other categories") }}
108 </h3>
109 <!-- Sorted A–Z, so a dropped row snaps into place. -->
110 <Draggable
111 :list="shownUnpinned"
112 item-key="value"
113 :group="GROUP"
114 :disabled="Boolean(search)"
115 :sort="false"
116 v-bind="DRAG_OPTIONS"
117 :class="LIST"
118 @change="onUnpinnedChange"
119 >
120 <template #item="{ element: category }">
121 <div :class="[ROW, search ? 'cursor-default' : 'cursor-grab']">
122 <span
123 v-if="!search"
124 class="lucide-grip-vertical size-4 shrink-0 text-ink-gray-4"
125 aria-hidden="true"
126 />
127 <span
128 :class="category.icon"
129 class="size-4 shrink-0 text-ink-gray-6"
130 />
131 <span class="min-w-0 flex-1 truncate text-base">
132 {{ category.label }}
133 </span>
134 <CategoryAccessBadge :access="category.access" />
135 <Button
136 variant="ghost"
137 size="xs"
138 icon="lucide-pin"
139 :label="__('Pin {0}', [category.label])"
140 :tooltip="__('Pin')"
141 :class="ACTION"
142 @click.stop="moveTo(category.value, 'pinned')"
143 />
144 </div>
145 </template>
146 <template #footer>
147 <div
148 v-if="!shownUnpinned.length"
149 class="px-3 py-6 text-center text-p-sm text-ink-gray-5"
150 >
151 {{
152 search ? __("No matches") : __("Every category is pinned")
153 }}
154 </div>
155 </template>
156 </Draggable>
157 </section>
158 </div>
159
160 <div v-else class="px-3 py-6 text-center text-p-sm text-ink-gray-5">
161 {{ __("No categories found") }}
162 </div>
163 </ScrollArea>
164
165 <div
166 class="flex shrink-0 justify-between border-t border-outline-gray-1 px-4 py-3"
167 >
168 <Button
169 variant="ghost"
170 :label="__('Reset changes')"
171 :disabled="!hasUnsavedChanges || isSaving"
172 @click="selected = [...initial]"
173 />
174 <Button
175 variant="solid"
176 :label="__('Save')"
177 :disabled="!hasUnsavedChanges"
178 :loading="isSaving"
179 @click="saveChanges"
180 />
181 </div>
182 </div>
183 </Dialog>
184</template>
185
186<script setup lang="ts">
187import { __ } from "../translation";
188import { askConfirm } from "../utils";
189import { Badge, Button, Dialog, ScrollArea, TextInput } from "frappe-ui";
190import { computed, ref, watch } from "vue";
191import Draggable from "vuedraggable";
192import CategoryAccessBadge from "./CategoryAccessBadge.vue";
193
194type Section = "pinned" | "unpinned";
195type Category = {
196 label: string;
197 value: string;
198 icon: string;
199 // Of its articles; "Mixed" when they differ.
200 access?: string;
201};
202type Change = {
203 added?: { element: Category; newIndex: number };
204 moved?: { element: Category; newIndex: number };
205};
206
207const ROW =
208 "group flex min-h-8 w-full select-none items-center gap-2 rounded-4 px-1.5 py-0.5 text-left text-ink-gray-8 hover:bg-surface-gray-2";
209const ACTION = "shrink-0 text-ink-gray-5";
210// No min height: the empty message is the drop target.
211const LIST = "rounded-6 border border-outline-gray-2 px-1.5 py-2";
212// Touch drags need a short hold, so the list still scrolls.
213const GROUP = "pinned-categories";
214const DRAG_OPTIONS = {
215 animation: 150,
216 delay: 200,
217 delayOnTouchOnly: true,
218 ghostClass: "opacity-40",
219};
220
221const props = defineProps<{
222 categories: Category[];
223 pinned: string[];
224 // Resolves true on success.
225 save: (pinned: string[]) => Promise<boolean>;
226}>();
227
228const show = defineModel<boolean>("open", { required: true });
229
230const query = ref("");
231const search = computed(() => query.value.trim().toLowerCase());
232const selected = ref<string[]>([]);
233const initial = ref<string[]>([]);
234const isSaving = ref(false);
235
236// Reset on open, so closing without Save drops edits.
237watch(show, (open) => {
238 if (!open) return;
239 query.value = "";
240 selected.value = [...props.pinned];
241 initial.value = [...props.pinned];
242});
243
244const byName = computed(
245 () => new Map(props.categories.map((category) => [category.value, category]))
246);
247
248const pinnedCategories = computed(() =>
249 selected.value
250 .map((name) => byName.value.get(name))
251 .filter((category): category is Category => category !== undefined)
252);
253
254// A–Z, as the home page lists them.
255const unpinnedCategories = computed(() =>
256 props.categories.filter(
257 (category) => !selected.value.includes(category.value)
258 )
259);
260
261function matches(category: Category) {
262 return category.label.toLowerCase().includes(search.value);
263}
264
265const shownUnpinned = computed(() =>
266 search.value
267 ? unpinnedCategories.value.filter(matches)
268 : unpinnedCategories.value
269);
270
271const hasUnsavedChanges = computed(
272 () => selected.value.join() !== initial.value.join()
273);
274
275function onPinnedChange({ added, moved }: Change) {
276 const change = added || moved;
277 if (!change) return;
278 // newIndex counts shown rows; pins to missing categories aren't shown
279 const names = pinnedCategories.value
280 .map((category) => category.value)
281 .filter((name) => name !== change.element.value);
282 names.splice(change.newIndex, 0, change.element.value);
283 const missing = selected.value.filter((name) => !byName.value.has(name));
284 selected.value = [...names, ...missing];
285}
286
287function onUnpinnedChange({ added }: Change) {
288 if (added) moveTo(added.element.value, "unpinned");
289}
290
291function moveTo(name: string, section: Section) {
292 const rest = selected.value.filter((row) => row !== name);
293 selected.value = section === "pinned" ? [...rest, name] : rest;
294}
295
296// Closing with edits asks first.
297function onOpenChange(open: boolean) {
298 if (open || !hasUnsavedChanges.value) {
299 show.value = open;
300 return;
301 }
302 askConfirm({
303 title: __("Unsaved changes"),
304 message: __(
305 "Are you sure you want to close? Unsaved changes will be lost."
306 ),
307 label: __("Confirm"),
308 action: () => {
309 show.value = false;
310 },
311 });
312}
313
314async function saveChanges() {
315 if (isSaving.value || !hasUnsavedChanges.value) return;
316 isSaving.value = true;
317 try {
318 if (await props.save(selected.value)) show.value = false;
319 } finally {
320 isSaving.value = false;
321 }
322}
323</script>
324