mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
719b32ad16 | ||
|
|
522fdbfd13 | ||
|
|
e4d1cbdb09 | ||
|
|
92b9deb6f0 | ||
|
|
41505a2ebb | ||
|
|
dfd726732b | ||
|
|
22e6b21ede |
@@ -55,6 +55,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
|
||||
|
||||
### Layout groups
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
onUpload: (attachment: Attachment) => void;
|
||||
onRemove: (attachmentId: string) => void;
|
||||
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
||||
showDropZone?: boolean;
|
||||
}
|
||||
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured }: Props = $props();
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let optimizing = $state(false);
|
||||
@@ -133,7 +134,7 @@
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 {attachment.featured ? 'block' : 'hidden group-hover:block'}"
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
@@ -142,7 +143,7 @@
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
|
||||
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
@@ -154,33 +155,56 @@
|
||||
{/if}
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDropZone}
|
||||
<!-- Drop zone for desktop, browse-only label for mobile -->
|
||||
<div
|
||||
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Mobile: compact browse button -->
|
||||
<div class="md:hidden">
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
|
||||
<span>Add images</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input-mobile"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
@@ -38,8 +37,6 @@
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
// Mutable note identity — allows transitioning from new → saved without closing
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -211,7 +208,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && showImageUpload}
|
||||
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
@@ -219,27 +216,9 @@
|
||||
onUpload={handleAttachmentUpload}
|
||||
onRemove={handleAttachmentRemove}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
showDropZone={showImageUpload}
|
||||
/>
|
||||
</div>
|
||||
{:else if noteId && attachmentsList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each attachmentsList as attachment}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
|
||||
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<div
|
||||
use:dragHandle
|
||||
aria-label="drag handle for {note.title || 'note'}"
|
||||
class="absolute top-2 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
class="absolute top-2 left-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
data-testid="note-drag-handle"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
|
||||
const colors = [
|
||||
'bg-red-500',
|
||||
'bg-orange-500',
|
||||
'bg-yellow-500',
|
||||
'bg-lime-500',
|
||||
'bg-green-500'
|
||||
'bg-[var(--destructive)]',
|
||||
'bg-[var(--destructive)]',
|
||||
'bg-[var(--primary)]',
|
||||
'bg-[var(--success-text)]',
|
||||
'bg-[var(--success-text)]'
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
<div class="flex gap-1">
|
||||
{#each { length: 4 } as _, i}
|
||||
<div
|
||||
class="h-1 flex-1 rounded-full transition-colors {i <= result.score
|
||||
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
|
||||
? colors[result.score]
|
||||
: 'bg-gray-200 dark:bg-gray-600'}"
|
||||
: 'bg-[var(--border-subtle)]'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{labels[result.score]}
|
||||
{#if result.feedback.warning}
|
||||
— {result.feedback.warning}
|
||||
|
||||
@@ -57,16 +57,18 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.tiptap-wrapper :global(.tiptap) {
|
||||
outline: none;
|
||||
min-height: 80px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||
|
||||
@@ -141,44 +141,44 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - KeepNotes</title>
|
||||
<title>Settings - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl py-8">
|
||||
<h1 class="mb-8 text-2xl font-bold text-gray-800 dark:text-gray-100">Settings</h1>
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 class="mb-8 text-2xl font-bold text-[var(--text)]">Settings</h1>
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Profile</h2>
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Profile</h2>
|
||||
|
||||
{#if profileMsg}
|
||||
<div class="mb-4 rounded p-3 text-sm {profileError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {profileError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{profileMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="display-name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
|
||||
<label for="display-name" class="mb-1 block text-sm font-medium text-[var(--text)]">Display Name</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-[var(--text)]">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={saveProfile}
|
||||
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Save profile
|
||||
</button>
|
||||
@@ -186,11 +186,11 @@
|
||||
</section>
|
||||
|
||||
<!-- Change Password -->
|
||||
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Change Password</h2>
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Change Password</h2>
|
||||
|
||||
{#if passwordMsg}
|
||||
<div class="mb-4 rounded p-3 text-sm {passwordError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {passwordError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{passwordMsg}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -200,24 +200,24 @@
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
placeholder="Current password"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="New password (min 8 characters)"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmNewPassword}
|
||||
placeholder="Confirm new password"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
onclick={changePassword}
|
||||
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
@@ -225,13 +225,13 @@
|
||||
</section>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Active Sessions</h2>
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">Active Sessions</h2>
|
||||
{#if activeSessions.length > 1}
|
||||
<button
|
||||
onclick={revokeAllOtherSessions}
|
||||
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
class="text-xs text-[var(--destructive)] hover:underline"
|
||||
>
|
||||
Log out everywhere else
|
||||
</button>
|
||||
@@ -239,28 +239,28 @@
|
||||
</div>
|
||||
|
||||
{#if !sessionsLoaded}
|
||||
<p class="text-sm text-gray-500">Loading sessions...</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">Loading sessions...</p>
|
||||
{:else if activeSessions.length === 0}
|
||||
<p class="text-sm text-gray-500">No active sessions</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">No active sessions</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each activeSessions as session (session.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-3 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||
<p class="text-sm font-medium text-[var(--text)]">
|
||||
{formatAgent(session.userAgent)}
|
||||
{#if session.isCurrent}
|
||||
<span class="ml-2 rounded bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/30 dark:text-green-200">current</span>
|
||||
<span class="ml-2 rounded-sm bg-[var(--success-bg)] px-2 py-0.5 text-xs text-[var(--success-text)]">current</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{#if !session.isCurrent}
|
||||
<button
|
||||
onclick={() => revokeSession(session.id)}
|
||||
class="rounded px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
@@ -273,11 +273,11 @@
|
||||
|
||||
<!-- Admin: User Management Link -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Administration</h2>
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Administration</h2>
|
||||
<a
|
||||
href="/settings/users"
|
||||
class="text-sm text-amber-600 hover:text-amber-700 hover:underline dark:text-amber-400"
|
||||
class="text-sm text-[var(--primary)] hover:text-[var(--primary-hover)] hover:underline"
|
||||
>
|
||||
Manage users →
|
||||
</a>
|
||||
+20
-20
@@ -86,21 +86,21 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Management - KeepNotes</title>
|
||||
<title>User Management - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl py-8">
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<div class="mb-8 flex items-center gap-4">
|
||||
<a href="/settings" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← Settings</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">User Management</h1>
|
||||
<a href="/settings" class="text-sm text-[var(--text-muted)] hover:text-[var(--primary)]">← Settings</a>
|
||||
<h1 class="text-2xl font-bold text-[var(--text)]">User Management</h1>
|
||||
</div>
|
||||
|
||||
<!-- Create User -->
|
||||
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Create User</h2>
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Create User</h2>
|
||||
|
||||
{#if createMsg}
|
||||
<div class="mb-4 rounded p-3 text-sm {createError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {createError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{createMsg}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -110,31 +110,31 @@
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="Email"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newDisplayName}
|
||||
placeholder="Display name (optional)"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={createUser}
|
||||
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Create user
|
||||
</button>
|
||||
@@ -142,25 +142,25 @@
|
||||
</section>
|
||||
|
||||
<!-- User List -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Users ({users.length})</h2>
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-4">
|
||||
<div>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-100">
|
||||
<p class="font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">admin</span>
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="rounded px-3 py-1 text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Make user' : 'Make admin'}
|
||||
@@ -168,7 +168,7 @@
|
||||
{#if user.id !== data.user?.id}
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
class="rounded px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -92,8 +92,7 @@
|
||||
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="password-input"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
@@ -102,8 +101,7 @@
|
||||
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="confirm-password-input"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
+1
-1
@@ -222,7 +222,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.8.0</span>
|
||||
<span class="footer-version">v0.8.1</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user