Compare commits

..
7 Commits
Author SHA1 Message Date
github-actions[bot] 719b32ad16 chore: bump version to 0.8.1 2026-03-09 00:29:30 +00:00
Frederic CabassutandClaude Opus 4.6 522fdbfd13 style: restyle settings pages to match parchment design system
- Move settings routes into (app) layout group for header/sidebar
- Replace all gray/white/dark-mode Tailwind classes with CSS variables
- Use rounded-sm, card-shadow, and parchment color tokens throughout
- Fix PasswordStrengthMeter to use themed colors
- Fix page titles from "KeepNotes" to "Crumbs"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:26:15 +01:00
Frederic CabassutandClaude Opus 4.6 e4d1cbdb09 fix: remove minlength attribute to fix e2e setup validation test
The HTML minlength="8" attribute triggered native browser validation
before the JS handler could run, preventing the custom error-message
element from appearing. JS already validates password length.

Also install missing zxcvbn and arctic dependencies from phase2 merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:23:11 +01:00
Frederic CabassutandClaude Opus 4.6 92b9deb6f0 docs: add mobile hover actions best practice to CLAUDE.md
Document the opacity-based pattern for hover actions that ensures
they're always visible on touch devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:59 +01:00
Frederic CabassutandClaude Opus 4.6 41505a2ebb fix: move drag handle to top-left to avoid overlap with pin icon
The drag handle was positioned top-right, overlapping with the pin
bookmark icon on pinned notes. Move it to top-left instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:46 +01:00
Frederic CabassutandClaude Opus 4.6 dfd726732b fix: increase focusable text area on mobile for rich text editor
Make the Tiptap editor fill the full wrapper height using flexbox so
tapping anywhere in the content area focuses the editor, not just the
small text area at the top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:34 +01:00
Frederic CabassutandClaude Opus 4.6 22e6b21ede fix: improve image actions for mobile and always show delete/star
- Use opacity-based visibility (max-md:opacity-100) for image hover
  actions so they're always accessible on touch devices
- Always show ImageUpload with delete/star when attachments exist,
  not just when in image upload mode
- Replace drag-and-drop zone with compact "Add images" button on mobile
- Add showDropZone prop to ImageUpload for conditional drop zone display
- Clean up unused lightbox state from NoteEditor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:08 +01:00
12 changed files with 125 additions and 121 deletions
+1
View File
@@ -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
View File
@@ -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)",
+54 -30
View File
@@ -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}
+2 -23
View File
@@ -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 -->
+1 -1
View File
@@ -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}
+4 -2
View File
@@ -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>
@@ -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>
+2 -4
View File
@@ -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
View File
@@ -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>