@kernhq/module-quire 0.10.9 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/models.d.ts +87 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +73 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +645 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +137 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +877 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +127 -1
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/schema.d.ts +482 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +115 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/index.d.ts +3 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +3 -0
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/organisation.d.ts +117 -0
- package/dist/server/services/organisation.d.ts.map +1 -0
- package/dist/server/services/organisation.js +319 -0
- package/dist/server/services/organisation.js.map +1 -0
- package/dist/server/services/pages.d.ts +16 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +62 -3
- package/dist/server/services/pages.js.map +1 -1
- package/migrations/0007_organisation.sql +114 -0
- package/migrations/meta/0007_snapshot.json +1588 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/ConfirmDialog.svelte +123 -0
- package/src/client/components/FavoriteStar.svelte +81 -0
- package/src/client/components/LabelChip.svelte +46 -0
- package/src/client/components/LabelManager.svelte +336 -0
- package/src/client/components/PageLabels.svelte +158 -0
- package/src/client/components/SidebarFavorites.svelte +262 -0
- package/src/client/components/SidebarRecents.svelte +98 -0
- package/src/client/components/SidebarSpaces.svelte +266 -1
- package/src/client/i18n.ts +387 -0
- package/src/client/index.ts +8 -0
- package/src/client/mock.ts +306 -0
- package/src/client/module.ts +18 -0
- package/src/client/pages/PageView.svelte +227 -4
- package/src/client/pages/TrashPage.svelte +378 -0
- package/src/client/query.ts +24 -0
- package/src/contract/models.ts +83 -0
- package/src/contract/permissions.ts +36 -0
- package/src/contract/router.ts +153 -1
package/package.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Are you sure?", said once and properly.
|
|
7
|
+
*
|
|
8
|
+
* Three destructive actions in this module needed the same dialog — moving a page to the trash,
|
|
9
|
+
* emptying one out of it for good, and deleting a label off every page that wears it — and the
|
|
10
|
+
* shape they share is the part worth getting right rather than writing three times:
|
|
11
|
+
*
|
|
12
|
+
* - **The body says what will happen, in numbers.** "Move to trash" fired with no confirmation and
|
|
13
|
+
* no way back, and it takes the whole subtree: deleting "Working here" silently took "Your first
|
|
14
|
+
* week" and "Time off" with it. A confirmation that does not say *how many* pages is barely
|
|
15
|
+
* better than none, so the caller passes a sentence that has already counted them.
|
|
16
|
+
* - **The confirm button is guarded, not disabled.** `busy` reaches the button on the *next*
|
|
17
|
+
* render, so two quick clicks are one render apart and both get through — the flag is set in the
|
|
18
|
+
* same tick as the click and read before anything is called. Disabling it would also blur the
|
|
19
|
+
* control the person is standing on and hand their focus to `<body>`; `aria-busy` says the same
|
|
20
|
+
* thing to a screen reader without moving anything.
|
|
21
|
+
* - **`pending` holds the same door shut until the sentence is true.** A caller whose body is still
|
|
22
|
+
* counting passes it, and confirming does nothing until the number arrives. Without it the body
|
|
23
|
+
* read "Loading…" while the danger button was fully live, so the one dialog whose job is to say
|
|
24
|
+
* how much goes could be answered before it had said anything at all. It is guarded rather than
|
|
25
|
+
* disabled for the reason above.
|
|
26
|
+
* - **Cancel is the first thing focus lands on.** `Dialog` focuses the first control in the body
|
|
27
|
+
* and there is nothing focusable there, so it keeps the close button — which is the safe one.
|
|
28
|
+
*/
|
|
29
|
+
interface Props {
|
|
30
|
+
open?: boolean
|
|
31
|
+
title: string
|
|
32
|
+
body: string
|
|
33
|
+
/** a second line for what cannot be undone — kept apart so it can be weighted differently */
|
|
34
|
+
note?: string | null
|
|
35
|
+
confirmLabel: string
|
|
36
|
+
danger?: boolean
|
|
37
|
+
/** the body does not know its numbers yet, so nothing may be confirmed against it */
|
|
38
|
+
pending?: boolean
|
|
39
|
+
onConfirm: () => Promise<void> | void
|
|
40
|
+
onCancel?: () => void
|
|
41
|
+
}
|
|
42
|
+
let {
|
|
43
|
+
open = $bindable(false),
|
|
44
|
+
title,
|
|
45
|
+
body,
|
|
46
|
+
note = null,
|
|
47
|
+
confirmLabel,
|
|
48
|
+
danger = false,
|
|
49
|
+
pending = false,
|
|
50
|
+
onConfirm,
|
|
51
|
+
onCancel,
|
|
52
|
+
}: Props = $props()
|
|
53
|
+
|
|
54
|
+
let busy = $state(false)
|
|
55
|
+
|
|
56
|
+
async function confirm() {
|
|
57
|
+
if (busy || pending) return
|
|
58
|
+
busy = true
|
|
59
|
+
try {
|
|
60
|
+
await onConfirm()
|
|
61
|
+
open = false
|
|
62
|
+
} finally {
|
|
63
|
+
busy = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cancel() {
|
|
68
|
+
if (busy) return
|
|
69
|
+
open = false
|
|
70
|
+
onCancel?.()
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<Dialog
|
|
75
|
+
bind:open
|
|
76
|
+
{title}
|
|
77
|
+
size="sm"
|
|
78
|
+
onOpenChange={(next) => {
|
|
79
|
+
if (!next) onCancel?.()
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<p class="body">{body}</p>
|
|
83
|
+
{#if note}<p class="note">{note}</p>{/if}
|
|
84
|
+
|
|
85
|
+
{#snippet footer()}
|
|
86
|
+
<div class="foot">
|
|
87
|
+
<Button variant="secondary" onclick={cancel}>{t('cancel')}</Button>
|
|
88
|
+
<Button
|
|
89
|
+
variant={danger ? 'danger' : 'primary'}
|
|
90
|
+
aria-busy={busy || pending}
|
|
91
|
+
onclick={() => void confirm()}
|
|
92
|
+
>
|
|
93
|
+
{confirmLabel}
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
{/snippet}
|
|
97
|
+
</Dialog>
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
.body {
|
|
101
|
+
margin: 0;
|
|
102
|
+
font-size: 13.5px;
|
|
103
|
+
line-height: 1.55;
|
|
104
|
+
color: var(--kern-ink-700);
|
|
105
|
+
text-wrap: pretty;
|
|
106
|
+
}
|
|
107
|
+
/*
|
|
108
|
+
* Muted with a colour, never with `opacity`: fading a paragraph against the page is how a line
|
|
109
|
+
* meant to read as secondary ends up unreadable, whatever its colour token says.
|
|
110
|
+
*/
|
|
111
|
+
.note {
|
|
112
|
+
margin: 10px 0 0;
|
|
113
|
+
font-size: 13px;
|
|
114
|
+
line-height: 1.55;
|
|
115
|
+
color: var(--kern-ink-450);
|
|
116
|
+
text-wrap: pretty;
|
|
117
|
+
}
|
|
118
|
+
.foot {
|
|
119
|
+
display: flex;
|
|
120
|
+
justify-content: flex-end;
|
|
121
|
+
gap: 8px;
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { IconButton } from '@kernhq/ui'
|
|
3
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import type { FavoriteEntry } from '../../contract/index.js'
|
|
5
|
+
import { getQuireApi } from '../api-instance.js'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import { quireKeys } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The star on a page, and the one place a favourite is made.
|
|
11
|
+
*
|
|
12
|
+
* `favorites.list` is one query for the whole workspace and the sidebar is already holding it, so
|
|
13
|
+
* this reads the same cache rather than asking whether this one page is starred — two screens that
|
|
14
|
+
* ask separately are two screens that can disagree about it. Both mutations answer with the whole
|
|
15
|
+
* ordered list, which goes straight into the cache: the sidebar redraws from the reply and nothing
|
|
16
|
+
* refetches.
|
|
17
|
+
*
|
|
18
|
+
* Guarded with a plain flag rather than `disabled`. The attribute lands a render later, so a
|
|
19
|
+
* double-click would star and unstar in one gesture; and disabling the button somebody has just
|
|
20
|
+
* pressed blurs it and hands their focus to `<body>`. `aria-busy` says the same thing without
|
|
21
|
+
* moving anything.
|
|
22
|
+
*/
|
|
23
|
+
interface Props {
|
|
24
|
+
workspaceId: string
|
|
25
|
+
pageId: string
|
|
26
|
+
}
|
|
27
|
+
const { workspaceId, pageId }: Props = $props()
|
|
28
|
+
|
|
29
|
+
const api = getQuireApi()
|
|
30
|
+
const client = useQueryClient()
|
|
31
|
+
|
|
32
|
+
const query = createQuery(() => ({
|
|
33
|
+
queryKey: quireKeys.favorites(workspaceId),
|
|
34
|
+
enabled: Boolean(workspaceId),
|
|
35
|
+
queryFn: () => api.favorites.list({ workspaceId }),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const starred = $derived((query.data ?? []).some((f) => f.pageId === pageId))
|
|
39
|
+
let busy = $state(false)
|
|
40
|
+
|
|
41
|
+
async function toggle() {
|
|
42
|
+
if (busy || !workspaceId || !pageId) return
|
|
43
|
+
busy = true
|
|
44
|
+
try {
|
|
45
|
+
const list: FavoriteEntry[] = starred
|
|
46
|
+
? await api.favorites.remove({ workspaceId, pageId })
|
|
47
|
+
: await api.favorites.add({ workspaceId, pageId })
|
|
48
|
+
client.setQueryData(quireKeys.favorites(workspaceId), list)
|
|
49
|
+
} finally {
|
|
50
|
+
busy = false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<span class="star" class:on={starred}>
|
|
56
|
+
<IconButton
|
|
57
|
+
icon="star"
|
|
58
|
+
variant="ghost"
|
|
59
|
+
label={starred ? t('favorite_remove') : t('favorite_add')}
|
|
60
|
+
aria-pressed={starred}
|
|
61
|
+
aria-busy={busy}
|
|
62
|
+
onclick={() => void toggle()}
|
|
63
|
+
/>
|
|
64
|
+
</span>
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
.star {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
}
|
|
70
|
+
/*
|
|
71
|
+
* A filled star, not a highlighted one. `Icon` draws a stroked outline, so "on" is the same glyph
|
|
72
|
+
* flooded with its own colour — which is what makes the two states tell apart at a glance and in
|
|
73
|
+
* a screenshot, where a background tint at this size does not.
|
|
74
|
+
*/
|
|
75
|
+
.star.on :global(.kib) {
|
|
76
|
+
color: var(--kern-warning);
|
|
77
|
+
}
|
|
78
|
+
.star.on :global(.kib svg) {
|
|
79
|
+
fill: currentColor;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Label } from '../../contract/index.js'
|
|
3
|
+
import { toneFor } from '../database/colours.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One label, painted.
|
|
7
|
+
*
|
|
8
|
+
* `LabelColour` is a closed enum whose members are exactly the keys of `TONES`, so the lookup can
|
|
9
|
+
* never miss — but it still goes through `toneFor`, which falls back to grey, because the same
|
|
10
|
+
* chips are drawn from data a server sent and a value nobody has seen before should be a grey chip
|
|
11
|
+
* rather than an unpainted one. Every pair in `TONES` is one the design tokens tuned for contrast
|
|
12
|
+
* in both themes, which is the whole reason a label's colour is a menu rather than a text field.
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
label: Label
|
|
16
|
+
size?: 'sm' | 'md'
|
|
17
|
+
}
|
|
18
|
+
const { label, size = 'md' }: Props = $props()
|
|
19
|
+
const tone = $derived(toneFor(label.colour))
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<span class="chip" class:sm={size === 'sm'} style:background={tone.bg} style:color={tone.fg}>
|
|
23
|
+
{label.name}
|
|
24
|
+
</span>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.chip {
|
|
28
|
+
display: inline-flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
max-width: 180px;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
text-overflow: ellipsis;
|
|
33
|
+
white-space: nowrap;
|
|
34
|
+
padding: 3px 9px;
|
|
35
|
+
border-radius: var(--kern-r-md);
|
|
36
|
+
font-size: 12px;
|
|
37
|
+
font-weight: 500;
|
|
38
|
+
letter-spacing: -0.005em;
|
|
39
|
+
line-height: 1.4;
|
|
40
|
+
}
|
|
41
|
+
.chip.sm {
|
|
42
|
+
padding: 1px 6px;
|
|
43
|
+
font-size: 11px;
|
|
44
|
+
border-radius: var(--kern-r-sm);
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, EmptyState, Icon, IconButton, Input, Skeleton } from '@kernhq/ui'
|
|
3
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import type { Label, LabelColour } from '../../contract/index.js'
|
|
5
|
+
import { getQuireApi } from '../api-instance.js'
|
|
6
|
+
import { OPTION_COLOURS, toneFor } from '../database/colours.js'
|
|
7
|
+
import { t } from '../i18n.js'
|
|
8
|
+
import { quireKeys } from '../query.js'
|
|
9
|
+
import ConfirmDialog from './ConfirmDialog.svelte'
|
|
10
|
+
import LabelChip from './LabelChip.svelte'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A space's vocabulary, where it belongs to whoever runs the space.
|
|
14
|
+
*
|
|
15
|
+
* Reading a label is `quire.space.view` and writing one is `quire.space.manage`, because renaming
|
|
16
|
+
* "Draft" changes what it means on every page wearing it — that is not something somebody who may
|
|
17
|
+
* edit one page should be able to do to everyone else's. This dialog is only offered to the second
|
|
18
|
+
* group; the picker on a page offers a way in here, and simply omits it for the first.
|
|
19
|
+
*
|
|
20
|
+
* One row is in edit mode at a time rather than every row carrying its own draft. Svelte state
|
|
21
|
+
* lives per component instance, not per `{#each}` iteration, so a draft per row would mean either
|
|
22
|
+
* a component per row or a map that has to be reconciled against the query on every refetch — and
|
|
23
|
+
* a map like that is exactly the sort of state an `$effect` ends up both reading and writing.
|
|
24
|
+
*/
|
|
25
|
+
interface Props {
|
|
26
|
+
open?: boolean
|
|
27
|
+
workspaceId: string
|
|
28
|
+
spaceId: string
|
|
29
|
+
}
|
|
30
|
+
let { open = $bindable(false), workspaceId, spaceId }: Props = $props()
|
|
31
|
+
|
|
32
|
+
const api = getQuireApi()
|
|
33
|
+
const client = useQueryClient()
|
|
34
|
+
|
|
35
|
+
const query = createQuery(() => ({
|
|
36
|
+
queryKey: quireKeys.labels(workspaceId, spaceId),
|
|
37
|
+
enabled: open && Boolean(workspaceId && spaceId),
|
|
38
|
+
queryFn: () => api.labels.list({ workspaceId, spaceId }),
|
|
39
|
+
}))
|
|
40
|
+
const labels = $derived(query.data ?? [])
|
|
41
|
+
|
|
42
|
+
/** The one row being edited, and what is being typed into it. */
|
|
43
|
+
let editingId = $state<string | null>(null)
|
|
44
|
+
let draftName = $state('')
|
|
45
|
+
let draftColour = $state<LabelColour>('grey')
|
|
46
|
+
|
|
47
|
+
let newName = $state('')
|
|
48
|
+
let newColour = $state<LabelColour>('grey')
|
|
49
|
+
let error = $state<string | null>(null)
|
|
50
|
+
let busy = $state(false)
|
|
51
|
+
|
|
52
|
+
let confirming = $state<Label | null>(null)
|
|
53
|
+
const confirmOpen = $derived(confirming !== null)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* "Draft" beside "draft" in one picker is broken data, so the clash is case-insensitive here for
|
|
57
|
+
* the same reason it is in the database. Caught locally as well as on the server: the server's
|
|
58
|
+
* answer is right and arrives after a round trip, and a name field should say so as you type.
|
|
59
|
+
*/
|
|
60
|
+
const clashes = (name: string, exceptId: string | null) =>
|
|
61
|
+
labels.some((l) => l.id !== exceptId && l.name.toLowerCase() === name.trim().toLowerCase())
|
|
62
|
+
|
|
63
|
+
const refresh = () => client.invalidateQueries({ queryKey: quireKeys.labels(workspaceId, spaceId) })
|
|
64
|
+
|
|
65
|
+
function startEditing(label: Label) {
|
|
66
|
+
editingId = label.id
|
|
67
|
+
draftName = label.name
|
|
68
|
+
draftColour = label.colour
|
|
69
|
+
error = null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stopEditing() {
|
|
73
|
+
editingId = null
|
|
74
|
+
error = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function create() {
|
|
78
|
+
const name = newName.trim()
|
|
79
|
+
if (busy || !name) return
|
|
80
|
+
if (clashes(name, null)) {
|
|
81
|
+
error = t('label_taken')
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
busy = true
|
|
85
|
+
error = null
|
|
86
|
+
try {
|
|
87
|
+
await api.labels.create({ workspaceId, spaceId, name, colour: newColour })
|
|
88
|
+
newName = ''
|
|
89
|
+
newColour = 'grey'
|
|
90
|
+
await refresh()
|
|
91
|
+
} catch (err) {
|
|
92
|
+
error = messageFor(err)
|
|
93
|
+
} finally {
|
|
94
|
+
busy = false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function save() {
|
|
99
|
+
const name = draftName.trim()
|
|
100
|
+
if (busy || !editingId || !name) return
|
|
101
|
+
if (clashes(name, editingId)) {
|
|
102
|
+
error = t('label_taken')
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
busy = true
|
|
106
|
+
error = null
|
|
107
|
+
try {
|
|
108
|
+
await api.labels.update({ workspaceId, labelId: editingId, name, colour: draftColour })
|
|
109
|
+
editingId = null
|
|
110
|
+
await refresh()
|
|
111
|
+
} catch (err) {
|
|
112
|
+
error = messageFor(err)
|
|
113
|
+
} finally {
|
|
114
|
+
busy = false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function remove(label: Label) {
|
|
119
|
+
await api.labels.remove({ workspaceId, labelId: label.id })
|
|
120
|
+
confirming = null
|
|
121
|
+
await refresh()
|
|
122
|
+
/*
|
|
123
|
+
* A label coming off pages is a change to those pages, and the chips drawing it are keyed per
|
|
124
|
+
* page. Nothing else would clear them: the server announces the label's own deletion, which
|
|
125
|
+
* refreshes this list and not the page that was wearing it.
|
|
126
|
+
*/
|
|
127
|
+
await client.invalidateQueries({ queryKey: ['quire', 'label', workspaceId] })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A conflict from the server is the same sentence the field already knows how to say. */
|
|
131
|
+
function messageFor(err: unknown): string {
|
|
132
|
+
const code = (err as { code?: string } | null)?.code
|
|
133
|
+
if (code === 'CONFLICT') return t('label_taken')
|
|
134
|
+
return err instanceof Error ? err.message : t('error')
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<Dialog bind:open title={t('labels_manage')} size="md">
|
|
139
|
+
{#if query.isLoading}
|
|
140
|
+
<div class="rows">
|
|
141
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="38px" />{/each}
|
|
142
|
+
</div>
|
|
143
|
+
{:else if query.isError}
|
|
144
|
+
<EmptyState icon="triangle-alert" title={t('labels_error')} description={t('retry')}>
|
|
145
|
+
{#snippet actions()}
|
|
146
|
+
<Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
|
|
147
|
+
{/snippet}
|
|
148
|
+
</EmptyState>
|
|
149
|
+
{:else}
|
|
150
|
+
{#if labels.length === 0}
|
|
151
|
+
<EmptyState
|
|
152
|
+
icon="tag"
|
|
153
|
+
compact
|
|
154
|
+
title={t('labels_empty')}
|
|
155
|
+
description={t('labels_empty_desc')}
|
|
156
|
+
/>
|
|
157
|
+
{:else}
|
|
158
|
+
<ul class="rows">
|
|
159
|
+
{#each labels as label (label.id)}
|
|
160
|
+
<li class="row">
|
|
161
|
+
{#if editingId === label.id}
|
|
162
|
+
<div class="edit">
|
|
163
|
+
<Input
|
|
164
|
+
bind:value={draftName}
|
|
165
|
+
size="sm"
|
|
166
|
+
aria-label={t('label_name')}
|
|
167
|
+
maxlength={60}
|
|
168
|
+
onkeydown={(e: KeyboardEvent) => {
|
|
169
|
+
if (e.key === 'Enter') {
|
|
170
|
+
e.preventDefault()
|
|
171
|
+
void save()
|
|
172
|
+
}
|
|
173
|
+
if (e.key === 'Escape') stopEditing()
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
<div class="swatches" role="radiogroup" aria-label={t('label_colour')}>
|
|
177
|
+
{#each OPTION_COLOURS as colour (colour)}
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="swatch"
|
|
181
|
+
role="radio"
|
|
182
|
+
aria-checked={draftColour === colour}
|
|
183
|
+
aria-label={t(`db_colour_${colour}`)}
|
|
184
|
+
style:background={toneFor(colour).bg}
|
|
185
|
+
style:color={toneFor(colour).fg}
|
|
186
|
+
onclick={() => (draftColour = colour as LabelColour)}
|
|
187
|
+
>
|
|
188
|
+
{#if draftColour === colour}<Icon name="check" size={13} strokeWidth={2.2} />{/if}
|
|
189
|
+
</button>
|
|
190
|
+
{/each}
|
|
191
|
+
</div>
|
|
192
|
+
<Button size="sm" aria-busy={busy} onclick={() => void save()}>{t('save')}</Button>
|
|
193
|
+
<Button size="sm" variant="ghost" onclick={stopEditing}>{t('cancel')}</Button>
|
|
194
|
+
</div>
|
|
195
|
+
{:else}
|
|
196
|
+
<LabelChip {label} />
|
|
197
|
+
<span class="spacer"></span>
|
|
198
|
+
<IconButton
|
|
199
|
+
icon="pencil"
|
|
200
|
+
size={26}
|
|
201
|
+
variant="ghost"
|
|
202
|
+
label={t('label_rename', { name: label.name })}
|
|
203
|
+
onclick={() => startEditing(label)}
|
|
204
|
+
/>
|
|
205
|
+
<IconButton
|
|
206
|
+
icon="trash-2"
|
|
207
|
+
size={26}
|
|
208
|
+
variant="ghost"
|
|
209
|
+
label={t('label_delete_title', { name: label.name })}
|
|
210
|
+
onclick={() => (confirming = label)}
|
|
211
|
+
/>
|
|
212
|
+
{/if}
|
|
213
|
+
</li>
|
|
214
|
+
{/each}
|
|
215
|
+
</ul>
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
<form
|
|
219
|
+
class="new"
|
|
220
|
+
onsubmit={(e) => {
|
|
221
|
+
e.preventDefault()
|
|
222
|
+
void create()
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<Input
|
|
226
|
+
bind:value={newName}
|
|
227
|
+
size="sm"
|
|
228
|
+
placeholder={t('label_new')}
|
|
229
|
+
aria-label={t('label_name')}
|
|
230
|
+
maxlength={60}
|
|
231
|
+
/>
|
|
232
|
+
<div class="swatches" role="radiogroup" aria-label={t('label_colour')}>
|
|
233
|
+
{#each OPTION_COLOURS as colour (colour)}
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
class="swatch"
|
|
237
|
+
role="radio"
|
|
238
|
+
aria-checked={newColour === colour}
|
|
239
|
+
aria-label={t(`db_colour_${colour}`)}
|
|
240
|
+
style:background={toneFor(colour).bg}
|
|
241
|
+
style:color={toneFor(colour).fg}
|
|
242
|
+
onclick={() => (newColour = colour as LabelColour)}
|
|
243
|
+
>
|
|
244
|
+
{#if newColour === colour}<Icon name="check" size={13} strokeWidth={2.2} />{/if}
|
|
245
|
+
</button>
|
|
246
|
+
{/each}
|
|
247
|
+
</div>
|
|
248
|
+
<Button size="sm" type="submit" icon="plus" aria-busy={busy} disabled={newName.trim() === ''}>
|
|
249
|
+
{t('add')}
|
|
250
|
+
</Button>
|
|
251
|
+
</form>
|
|
252
|
+
|
|
253
|
+
{#if error}<p class="err" role="alert">{error}</p>{/if}
|
|
254
|
+
{/if}
|
|
255
|
+
</Dialog>
|
|
256
|
+
|
|
257
|
+
<ConfirmDialog
|
|
258
|
+
open={confirmOpen}
|
|
259
|
+
title={t('label_delete_title', { name: confirming?.name ?? '' })}
|
|
260
|
+
body={t('label_delete_body')}
|
|
261
|
+
confirmLabel={t('delete')}
|
|
262
|
+
danger
|
|
263
|
+
onCancel={() => (confirming = null)}
|
|
264
|
+
onConfirm={async () => {
|
|
265
|
+
if (confirming) await remove(confirming)
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
|
|
269
|
+
<style>
|
|
270
|
+
.rows {
|
|
271
|
+
display: flex;
|
|
272
|
+
flex-direction: column;
|
|
273
|
+
gap: 4px;
|
|
274
|
+
margin: 0;
|
|
275
|
+
padding: 0;
|
|
276
|
+
list-style: none;
|
|
277
|
+
}
|
|
278
|
+
.row {
|
|
279
|
+
display: flex;
|
|
280
|
+
align-items: center;
|
|
281
|
+
gap: 8px;
|
|
282
|
+
min-height: 38px;
|
|
283
|
+
padding-inline: 2px;
|
|
284
|
+
border-radius: var(--kern-r-md);
|
|
285
|
+
}
|
|
286
|
+
.row:hover {
|
|
287
|
+
background: var(--kern-surface-hover);
|
|
288
|
+
}
|
|
289
|
+
.spacer {
|
|
290
|
+
flex: 1;
|
|
291
|
+
}
|
|
292
|
+
.edit {
|
|
293
|
+
display: flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
gap: 8px;
|
|
296
|
+
flex-wrap: wrap;
|
|
297
|
+
width: 100%;
|
|
298
|
+
padding-block: 4px;
|
|
299
|
+
}
|
|
300
|
+
.new {
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
gap: 8px;
|
|
304
|
+
flex-wrap: wrap;
|
|
305
|
+
margin-block-start: 14px;
|
|
306
|
+
padding-block-start: 14px;
|
|
307
|
+
border-block-start: 1px solid var(--kern-border);
|
|
308
|
+
}
|
|
309
|
+
.swatches {
|
|
310
|
+
display: flex;
|
|
311
|
+
gap: 4px;
|
|
312
|
+
flex-wrap: wrap;
|
|
313
|
+
}
|
|
314
|
+
/*
|
|
315
|
+
* 26px, because a control under 24px with another beside it is a target the audit fails — and a
|
|
316
|
+
* row of eight colours is the most crowded thing in this dialog.
|
|
317
|
+
*/
|
|
318
|
+
.swatch {
|
|
319
|
+
width: 26px;
|
|
320
|
+
height: 26px;
|
|
321
|
+
display: inline-grid;
|
|
322
|
+
place-items: center;
|
|
323
|
+
border: 1px solid var(--kern-border);
|
|
324
|
+
border-radius: var(--kern-r-md);
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
padding: 0;
|
|
327
|
+
}
|
|
328
|
+
.swatch[aria-checked='true'] {
|
|
329
|
+
border-color: var(--kern-ink-900);
|
|
330
|
+
}
|
|
331
|
+
.err {
|
|
332
|
+
margin: 10px 0 0;
|
|
333
|
+
font-size: 12.5px;
|
|
334
|
+
color: var(--kern-danger);
|
|
335
|
+
}
|
|
336
|
+
</style>
|