5dcadd1c20
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a swappable token bundle so other presets can be added later; dark mode and the user-tunable accent hue are retained. Foundation - app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens - Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept - h1/h2/h3 render in Fraunces via base layer Chrome and surfaces - Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites - 29 widgets + integration renderers: cozy card shells, room-palette charts - Default background is a static warm "cozy" glow (mesh demoted, rAF gated on prefers-reduced-motion) System-wide - Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning to status tokens, categorical to room palette, errors to destructive - Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem]; soft-shadow vocabulary only; focus-visible:ring-primary/30 - Forms, admin tables (now cozy cards), dialogs, popovers, auth screens a11y: reduced-motion guards; darker status "ink" text for AA on cream. Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color, user-tunable). Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors. Design refs + system sheet in design-mockups/.
304 lines
9.6 KiB
Svelte
304 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import { t } from 'svelte-i18n';
|
|
import { TargetType, PermissionLevel } from '$lib/utils/constants.js';
|
|
import {
|
|
loadBoardPermissions,
|
|
grantBoardPermission,
|
|
revokeBoardPermission,
|
|
getTargetName as resolveTargetName,
|
|
type PermissionRecord,
|
|
type SelectOption
|
|
} from '$lib/utils/boardPermissions.js';
|
|
|
|
interface Props {
|
|
boardId: string;
|
|
boardName: string;
|
|
isGuestAccessible: boolean;
|
|
users: SelectOption[];
|
|
groups: SelectOption[];
|
|
onClose: () => void;
|
|
onGuestToggle: (value: boolean) => void;
|
|
}
|
|
|
|
let {
|
|
boardId,
|
|
boardName,
|
|
isGuestAccessible,
|
|
users,
|
|
groups,
|
|
onClose,
|
|
onGuestToggle
|
|
}: Props = $props();
|
|
|
|
let permissions = $state<PermissionRecord[]>([]);
|
|
let loading = $state(true);
|
|
let errorMessage = $state('');
|
|
let copySuccess = $state(false);
|
|
let copyTimerId = $state<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
let selectedTargetType = $state<string>(TargetType.USER);
|
|
let selectedTargetId = $state('');
|
|
let selectedLevel = $state<string>(PermissionLevel.VIEW);
|
|
let searchQuery = $state('');
|
|
|
|
let targetOptions = $derived(
|
|
selectedTargetType === TargetType.USER ? users : groups
|
|
);
|
|
|
|
let filteredTargetOptions = $derived(
|
|
searchQuery.length > 0
|
|
? targetOptions.filter((opt) =>
|
|
opt.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
: targetOptions
|
|
);
|
|
|
|
async function loadPermissions() {
|
|
loading = true;
|
|
errorMessage = '';
|
|
try {
|
|
permissions = await loadBoardPermissions(boardId);
|
|
} catch (err) {
|
|
errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function handleGrant() {
|
|
if (!selectedTargetId) return;
|
|
errorMessage = '';
|
|
try {
|
|
await grantBoardPermission(boardId, selectedTargetType, selectedTargetId, selectedLevel);
|
|
selectedTargetId = '';
|
|
searchQuery = '';
|
|
await loadPermissions();
|
|
} catch (err) {
|
|
errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
}
|
|
}
|
|
|
|
async function handleRevoke(perm: PermissionRecord) {
|
|
errorMessage = '';
|
|
try {
|
|
await revokeBoardPermission(boardId, perm.targetType, perm.targetId);
|
|
await loadPermissions();
|
|
} catch (err) {
|
|
errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
}
|
|
}
|
|
|
|
function getTargetName(targetType: string, targetId: string): string {
|
|
return resolveTargetName(targetType, targetId, users, groups);
|
|
}
|
|
|
|
function getLevelLabel(level: string): string {
|
|
switch (level) {
|
|
case PermissionLevel.VIEW:
|
|
return $t('admin.perm_view');
|
|
case PermissionLevel.EDIT:
|
|
return $t('admin.perm_edit');
|
|
case PermissionLevel.ADMIN:
|
|
return $t('admin.perm_admin');
|
|
default:
|
|
return level;
|
|
}
|
|
}
|
|
|
|
async function handleCopyLink() {
|
|
try {
|
|
const url = `${window.location.origin}/boards/${boardId}`;
|
|
await navigator.clipboard.writeText(url);
|
|
copySuccess = true;
|
|
if (copyTimerId !== null) {
|
|
clearTimeout(copyTimerId);
|
|
}
|
|
copyTimerId = setTimeout(() => {
|
|
copySuccess = false;
|
|
copyTimerId = null;
|
|
}, 2000);
|
|
} catch {
|
|
// Fallback: ignore if clipboard API not available
|
|
}
|
|
}
|
|
|
|
function handleBackdropClick(event: MouseEvent) {
|
|
if (event.target === event.currentTarget) {
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
// Load permissions on mount; clean up copy timer on destroy
|
|
$effect(() => {
|
|
loadPermissions();
|
|
return () => {
|
|
if (copyTimerId !== null) {
|
|
clearTimeout(copyTimerId);
|
|
}
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onclick={handleBackdropClick}
|
|
role="presentation"
|
|
>
|
|
<div class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]" role="dialog" aria-modal="true">
|
|
<!-- Header -->
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold text-card-foreground">
|
|
{$t('board.share_title', { values: { name: boardName } })}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onclick={onClose}
|
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
aria-label={$t('common.cancel')}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Copy link -->
|
|
<div class="mb-4 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onclick={handleCopyLink}
|
|
class="flex items-center gap-2 rounded-xl border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
</svg>
|
|
{copySuccess ? $t('board.share_copied') : $t('board.share_copy_link')}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Guest access toggle -->
|
|
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3">
|
|
<label class="flex items-center gap-3 text-sm text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={isGuestAccessible}
|
|
onchange={(e) => onGuestToggle(e.currentTarget.checked)}
|
|
class="h-4 w-4 rounded border-input accent-primary"
|
|
/>
|
|
<div>
|
|
<span class="font-medium">{$t('board.guest_accessible')}</span>
|
|
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Quick add permission -->
|
|
<div class="mb-4 rounded-lg border border-border p-3">
|
|
<h3 class="mb-2 text-sm font-medium text-card-foreground">{$t('board.share_add_access')}</h3>
|
|
<div class="flex gap-2">
|
|
<select
|
|
bind:value={selectedTargetType}
|
|
onchange={() => { selectedTargetId = ''; searchQuery = ''; }}
|
|
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
|
>
|
|
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
|
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
|
</select>
|
|
<div class="relative flex-1">
|
|
<input
|
|
type="text"
|
|
bind:value={searchQuery}
|
|
placeholder={$t('board.access_search_placeholder')}
|
|
class="w-full rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
|
/>
|
|
{#if searchQuery.length > 0 && filteredTargetOptions.length > 0 && !selectedTargetId}
|
|
<div class="absolute z-10 mt-1 max-h-32 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
|
{#each filteredTargetOptions as option (option.id)}
|
|
<button
|
|
type="button"
|
|
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
|
onclick={() => { selectedTargetId = option.id; searchQuery = option.name; }}
|
|
>
|
|
{option.name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<select
|
|
bind:value={selectedLevel}
|
|
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
|
>
|
|
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
|
|
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
|
|
<option value={PermissionLevel.ADMIN}>{$t('admin.perm_admin')}</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onclick={handleGrant}
|
|
disabled={!selectedTargetId}
|
|
class="shrink-0 rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{$t('common.add')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if errorMessage}
|
|
<p class="mb-3 text-sm text-destructive">{errorMessage}</p>
|
|
{/if}
|
|
|
|
<!-- Current access list -->
|
|
<div class="max-h-48 overflow-y-auto">
|
|
{#if loading}
|
|
<p class="text-sm text-muted-foreground">{$t('board.access_loading')}</p>
|
|
{:else if permissions.length > 0}
|
|
<h3 class="mb-2 text-sm font-medium text-card-foreground">{$t('board.share_current_access')}</h3>
|
|
<div class="space-y-1">
|
|
{#each permissions as perm (perm.id)}
|
|
<div class="flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/50">
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
|
|
{#if perm.targetType === TargetType.USER}
|
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
|
<circle cx="12" cy="7" r="4" />
|
|
{:else}
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
{/if}
|
|
</svg>
|
|
<span class="text-foreground">{getTargetName(perm.targetType, perm.targetId)}</span>
|
|
<span class="rounded-full bg-accent px-2 py-0.5 text-xs text-accent-foreground">
|
|
{getLevelLabel(perm.level)}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => handleRevoke(perm)}
|
|
class="text-xs text-destructive hover:underline"
|
|
>
|
|
{$t('admin.perm_revoke')}
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-sm text-muted-foreground">{$t('board.access_none')}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|