feat: ui theming & improved clickOutside handler (for modals)

This commit is contained in:
ae 2025-05-06 12:59:29 +03:00
parent a950da9412
commit b3b897be85
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
5 changed files with 293 additions and 264 deletions

View File

@ -13,7 +13,7 @@
:root { :root {
--light-background: #f5f5f5; --light-background: #f5f5f5;
--light-foreground: #e0e0e0; --light-foreground: #e0e0e0;
--light-accent: #303052; --light-accent: #2e2e88;
--light-text: rgb(34, 40, 49); --light-text: rgb(34, 40, 49);
--light-highlight-text: #c28e4a; --light-highlight-text: #c28e4a;
--light-error-text: #4b0000; --light-error-text: #4b0000;
@ -910,6 +910,14 @@
@apply fixed inset-0 z-40 flex items-center justify-center backdrop-blur-xs; @apply fixed inset-0 z-40 flex items-center justify-center backdrop-blur-xs;
} }
.modal-exit-button {
@apply flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--light-accent)] bg-transparent p-0 text-[var(--light-text)] hover:bg-[var(--light-foreground)]/80;
}
.dark .modal-exit-button {
@apply hover:bg-[var(--dark-foreground)]/80;
}
.modal-content { .modal-content {
@apply mx-4 max-h-[90vh] w-full max-w-[90vw] overflow-y-auto rounded-lg border-2 border-[var(--light-accent)]/10 bg-[var(--light-background)] shadow-lg; @apply mx-4 max-h-[90vh] w-full max-w-[90vw] overflow-y-auto rounded-lg border-2 border-[var(--light-accent)]/10 bg-[var(--light-background)] shadow-lg;
} }

View File

@ -4,7 +4,7 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import type { User } from "$lib/logic/model" import type { User } from "$lib/logic/model"
import Delete from "$lib/icons/sidebar/Delete.svelte" import Delete from "$lib/icons/sidebar/Delete.svelte"
import { formatDateShort } from "$lib/util/contentVisual" import { clickOutside, formatDateShort } from "$lib/util/contentVisual"
// props // props
export let onClose: () => void export let onClose: () => void
@ -46,14 +46,6 @@
} }
} }
const handleClickOutside = (event: MouseEvent) => {
// close the modal if user clicks outside of it
const target = event.target as HTMLElement
if (target.classList.contains("modal-backdrop")) {
onClose()
}
}
const handleModalContentKeydown = (_event: KeyboardEvent) => { const handleModalContentKeydown = (_event: KeyboardEvent) => {
// accessibility compliance handler, actual handling is done by the global keydown handler // accessibility compliance handler, actual handling is done by the global keydown handler
} }
@ -71,71 +63,72 @@
<div <div
class="modal-backdrop" class="modal-backdrop"
on:click={handleClickOutside}
on:keydown={handleKeydown} on:keydown={handleKeydown}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
> >
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div use:clickOutside={() => onClose()}>
bind:this={modalContent} <div
class="modal-content max-w-xl" bind:this={modalContent}
on:click|stopPropagation class="modal-content max-w-xl"
on:keydown={handleModalContentKeydown} on:click|stopPropagation
role="document" on:keydown={handleModalContentKeydown}
tabindex="-1" role="document"
> tabindex="-1"
<!-- Header (title + close button) --> >
<div class="modal-section flex items-center justify-between" role="heading" aria-level="1"> <!-- Header (title + close button) -->
<h2 class="text-xl font-bold">Administration</h2> <div class="modal-section flex items-center justify-between" role="heading" aria-level="1">
<button on:click={onClose} class="sidebar-button" aria-label="Close settings"> <h2 class="text-xl font-bold">Administration</h2>
<Close /> <button on:click={onClose} class="modal-exit-button" aria-label="Close settings">
</button> <Close />
</div> </button>
</div>
<!-- Account information --> <!-- Account information -->
<div class="modal-section border-b-0"> <div class="modal-section border-b-0">
<h3 class="modal-section-title">Active users</h3> <h3 class="modal-section-title">Active users</h3>
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
{#if users !== null && users.length > 0} {#if users !== null && users.length > 0}
<ul class="flex flex-col items-center space-y-1" role="listbox"> <ul class="flex flex-col items-center space-y-1" role="listbox">
{#each users as user} {#each users as user}
<li class="modal-list-item"> <li class="modal-list-item">
<div class="flex w-full items-center justify-between"> <div class="flex w-full items-center justify-between">
<h3 class="modal-list-item-title">{user.username}</h3> <h3 class="modal-list-item-title">{user.username}</h3>
<!-- Delete button --> <!-- Delete button -->
{#if $currentUser !== null && user.id === $currentUser.id} {#if $currentUser !== null && user.id === $currentUser.id}
<button <button
disabled disabled
class="sidebar-button sidebar-list-item-delete-button" class="sidebar-button sidebar-list-item-delete-button"
aria-label="Delete note" aria-label="Delete note"
> >
<Delete classString="h-3 w-3" /> <Delete classString="h-3 w-3" />
</button> </button>
{:else} {:else}
<button <button
on:click={(e) => handleDeleteUser(e, user.id, user.username)} on:click={(e) => handleDeleteUser(e, user.id, user.username)}
class="sidebar-button sidebar-list-item-delete-button" class="sidebar-button sidebar-list-item-delete-button"
aria-label="Delete note" aria-label="Delete note"
> >
<Delete classString="h-3 w-3" /> <Delete classString="h-3 w-3" />
</button> </button>
{/if} {/if}
</div> </div>
<p class="modal-list-item-metadata">{user.id}</p> <p class="modal-list-item-metadata">{user.id}</p>
<p class="modal-list-item-metadata"> <p class="modal-list-item-metadata">
Created: {formatDateShort(user.createdAt)}, Updated: {formatDateShort( Created: {formatDateShort(user.createdAt)}, Updated: {formatDateShort(
user.updatedAt user.updatedAt
)} )}
</p> </p>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
import Close from "$lib/icons/Close.svelte" import Close from "$lib/icons/Close.svelte"
import { isPasswordValid } from "$lib/util/authValidation" import { isPasswordValid } from "$lib/util/authValidation"
import { onMount } from "svelte" import { onMount } from "svelte"
import { formatDateLong } from "$lib/util/contentVisual" import { clickOutside, formatDateLong } from "$lib/util/contentVisual"
// props // props
export let onClose: () => void export let onClose: () => void
@ -103,14 +103,6 @@
} }
} }
const handleClickOutside = (event: MouseEvent) => {
// close the modal if user clicks outside of it
const target = event.target as HTMLElement
if (target.classList.contains("modal-backdrop")) {
onClose()
}
}
const handleModalContentKeydown = (_event: KeyboardEvent) => { const handleModalContentKeydown = (_event: KeyboardEvent) => {
// accessibility compliance handler, actual handling is done by the global keydown handler // accessibility compliance handler, actual handling is done by the global keydown handler
} }
@ -126,161 +118,162 @@
<div <div
class="modal-backdrop" class="modal-backdrop"
on:click={handleClickOutside}
on:keydown={handleKeydown} on:keydown={handleKeydown}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
> >
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div use:clickOutside={() => onClose()}>
bind:this={modalContent} <div
class="modal-content max-w-xl" bind:this={modalContent}
on:click|stopPropagation class="modal-content max-w-xl"
on:keydown={handleModalContentKeydown} on:click|stopPropagation
role="document" on:keydown={handleModalContentKeydown}
tabindex="-1" role="document"
> tabindex="-1"
<!-- Header (title + close button) --> >
<div class="modal-section flex items-center justify-between" role="heading" aria-level="1"> <!-- Header (title + close button) -->
<h2 class="text-xl font-bold">Settings</h2> <div class="modal-section flex items-center justify-between" role="heading" aria-level="1">
<button on:click={onClose} class="sidebar-button" aria-label="Close settings"> <h2 class="text-xl font-bold">Settings</h2>
<Close /> <button on:click={onClose} class="modal-exit-button" aria-label="Close settings">
</button> <Close />
</div>
<!-- Account information -->
<div class="modal-section">
<h3 class="modal-section-title">Account Data</h3>
<table class="modal-table">
<tbody>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Username</th>
<td class="modal-table-row-item">{$currentUser?.username}</td>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Account created</th>
<td class="modal-table-row-item"
>{$currentUser?.createdAt !== undefined
? formatDateLong($currentUser?.createdAt)
: ""}</td
>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Password updated</th>
<td class="modal-table-row-item"
>{$currentUser?.createdAt !== undefined
? formatDateLong($currentUser?.updatedAt)
: ""}</td
>
</tr>
{#if $currentUser?.isAdmin}
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Admin permissions</th>
<td class="modal-table-row-item">{$currentUser?.isAdmin}</td>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Account settings -->
<div class="modal-section">
<h3 class="modal-section-title">Account Settings</h3>
{#if changePasswordModalError}
<div class="error mb-4" role="alert">
{changePasswordModalError}
</div>
{/if}
<div class="space-y-4">
<h4 class="font-bold">Change Password</h4>
<!-- Current Password -->
<div class="form-group">
<label class="form-label" for="currentPassword">Current Password</label>
<input
type="password"
id="currentPassword"
bind:value={currentPassword}
class="auth-input-field"
/>
</div>
<!-- New Password -->
<div class="form-group">
<label class="form-label" for="newPassword">New Password</label>
<input
type="password"
id="newPassword"
bind:value={newPassword}
class="auth-input-field"
/>
</div>
<!-- Confirm New Password -->
<div class="form-group">
<label class="form-label" for="confirmPassword">Confirm New Password</label>
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
class="auth-input-field"
/>
</div>
<button on:click={changePassword} class="auth-button"> Change Password </button>
</div>
</div>
<!-- "Danger zone" -->
<div class="modal-section border-b-0">
<h3 class="modal-section-title text-red-500">Danger Zone</h3>
{#if deleteUserModalError}
<div class="error mb-4" role="alert">
{deleteUserModalError}
</div>
{/if}
<div class="space-y-4">
<p class="text-sm">
Deleting your account will permanently remove all your notes and account information. This
action cannot be undone.
</p>
<!-- Confirm Password -->
<div class="form-group">
<label class="form-label" for="deleteConfirmPassword">Your Password</label>
<input
type="password"
id="deleteConfirmPassword"
bind:value={deleteConfirmPassword}
class="auth-input-field"
/>
</div>
<!-- Type DELETE to confirm -->
<div class="form-group">
<label class="form-label" for="deleteConfirmText">Type DELETE to confirm</label>
<input
type="text"
id="deleteConfirmText"
bind:value={deleteConfirmText}
class="auth-input-field"
placeholder="DELETE"
/>
</div>
<button
on:click={deleteAccount}
class="auth-button bg-red-500 text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={deleteConfirmText !== "DELETE" || !deleteConfirmPassword}
>
Delete My Account
</button> </button>
</div> </div>
<!-- Account information -->
<div class="modal-section">
<h3 class="modal-section-title">Account Data</h3>
<table class="modal-table">
<tbody>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Username</th>
<td class="modal-table-row-item">{$currentUser?.username}</td>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Account created</th>
<td class="modal-table-row-item"
>{$currentUser?.createdAt !== undefined
? formatDateLong($currentUser?.createdAt)
: ""}</td
>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Password updated</th>
<td class="modal-table-row-item"
>{$currentUser?.createdAt !== undefined
? formatDateLong($currentUser?.updatedAt)
: ""}</td
>
</tr>
{#if $currentUser?.isAdmin}
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Admin permissions</th>
<td class="modal-table-row-item">{$currentUser?.isAdmin}</td>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Account settings -->
<div class="modal-section">
<h3 class="modal-section-title">Account Settings</h3>
{#if changePasswordModalError}
<div class="error mb-4" role="alert">
{changePasswordModalError}
</div>
{/if}
<div class="space-y-4">
<h4 class="font-bold">Change Password</h4>
<!-- Current Password -->
<div class="form-group">
<label class="form-label" for="currentPassword">Current Password</label>
<input
type="password"
id="currentPassword"
bind:value={currentPassword}
class="auth-input-field"
/>
</div>
<!-- New Password -->
<div class="form-group">
<label class="form-label" for="newPassword">New Password</label>
<input
type="password"
id="newPassword"
bind:value={newPassword}
class="auth-input-field"
/>
</div>
<!-- Confirm New Password -->
<div class="form-group">
<label class="form-label" for="confirmPassword">Confirm New Password</label>
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
class="auth-input-field"
/>
</div>
<button on:click={changePassword} class="auth-button"> Change Password </button>
</div>
</div>
<!-- "Danger zone" -->
<div class="modal-section border-b-0">
<h3 class="modal-section-title text-red-500">Danger Zone</h3>
{#if deleteUserModalError}
<div class="error mb-4" role="alert">
{deleteUserModalError}
</div>
{/if}
<div class="space-y-4">
<p class="text-sm">
Deleting your account will permanently remove all your notes and account information.
This action cannot be undone.
</p>
<!-- Confirm Password -->
<div class="form-group">
<label class="form-label" for="deleteConfirmPassword">Your Password</label>
<input
type="password"
id="deleteConfirmPassword"
bind:value={deleteConfirmPassword}
class="auth-input-field"
/>
</div>
<!-- Type DELETE to confirm -->
<div class="form-group">
<label class="form-label" for="deleteConfirmText">Type DELETE to confirm</label>
<input
type="text"
id="deleteConfirmText"
bind:value={deleteConfirmText}
class="auth-input-field"
placeholder="DELETE"
/>
</div>
<button
on:click={deleteAccount}
class="auth-button bg-red-500 text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={deleteConfirmText !== "DELETE" || !deleteConfirmPassword}
>
Delete My Account
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,7 +12,12 @@
import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte" import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte"
import Exit from "$lib/icons/sidebar/Exit.svelte" import Exit from "$lib/icons/sidebar/Exit.svelte"
import ThemeToggle from "./ThemeToggle.svelte" import ThemeToggle from "./ThemeToggle.svelte"
import { formatDateLong, formatDateShort, parseExpirationPrefix } from "$lib/util/contentVisual" import {
clickOutside,
formatDateLong,
formatDateShort,
parseExpirationPrefix
} from "$lib/util/contentVisual"
import type { FullNote, NoteMetadata } from "$lib/logic/model" import type { FullNote, NoteMetadata } from "$lib/logic/model"
// props // props
@ -210,55 +215,63 @@
<!-- User section (single button) with dropdown --> <!-- User section (single button) with dropdown -->
<div class="relative flex flex-col px-2 py-3"> <div class="relative flex flex-col px-2 py-3">
<button <div
on:click={toggleUserMenu} use:clickOutside={() => {
class="sidebar-action-button flex-wrap justify-between px-4 py-4" if (isUserMenuOpen) {
isUserMenuOpen = false
}
}}
> >
<div class="sidebar-user-button-content-container"> <button
<User classString="h-6 w-6 flex-shrink-0" /> on:click={toggleUserMenu}
class="sidebar-action-button flex-wrap justify-between px-4 py-4"
>
<div class="sidebar-user-button-content-container">
<User classString="h-6 w-6 flex-shrink-0" />
<div class="sidebar-user-button-username-container"> <div class="sidebar-user-button-username-container">
<span class="sidebar-user-button-username-text">Logged in as {username}</span> <span class="sidebar-user-button-username-text">Logged in as {username}</span>
</div>
{#if isUserMenuOpen}
<ChevronDown classString="h-6 w-6 flex-shrink-0" />
{:else}
<ChevronUp classString="h-6 w-6 flex-shrink-0" />
{/if}
</div> </div>
</button>
{#if isUserMenuOpen} <!-- User actions dropdown menu -->
<ChevronDown classString="h-6 w-6 flex-shrink-0" /> {#if isUserMenuOpen}
{:else} <div class="sidebar-user-dropdown">
<ChevronUp classString="h-6 w-6 flex-shrink-0" /> <div class="flex flex-col p-0">
{/if} <button
</div> on:click={toggleSettingsModal}
</button> class="sidebar-action-button rounded-none rounded-t-lg border-0 border-b"
>
<SettingsGear classString="mr-3 h-5 w-5" />
Settings
</button>
<!-- User actions dropdown menu --> <button
{#if isUserMenuOpen} on:click={toggleAdminModal}
<div class="sidebar-user-dropdown"> class="sidebar-action-button rounded-none border-0 border-y"
<div class="flex flex-col p-0"> >
<button <AdminWrench classString="mr-3 h-5 w-5" />
on:click={toggleSettingsModal} Admin view
class="sidebar-action-button rounded-none rounded-t-lg border-0 border-b" </button>
>
<SettingsGear classString="mr-3 h-5 w-5" />
Settings
</button>
<button <button
on:click={toggleAdminModal} on:click={logout}
class="sidebar-action-button rounded-none border-0 border-y" class="sidebar-action-button rounded-none rounded-b-lg border-0 border-t"
> >
<AdminWrench classString="mr-3 h-5 w-5" /> <Exit classString="mr-3 h-5 w-5" />
Admin view Log out
</button> </button>
</div>
<button
on:click={logout}
class="sidebar-action-button rounded-none rounded-b-lg border-0 border-t"
>
<Exit classString="mr-3 h-5 w-5" />
Log out
</button>
</div> </div>
</div> {/if}
{/if} </div>
</div> </div>
</aside> </aside>
</div> </div>

View File

@ -50,3 +50,25 @@ export const formatTitleWithHighlight = (title: string): string => {
return `<span class="note-title-exp-highlight">${expirationPrefix}</span> ${cleanTitle}` return `<span class="note-title-exp-highlight">${expirationPrefix}</span> ${cleanTitle}`
} }
export const clickOutside = (
node: HTMLElement,
callbackFunction: () => void
): {
destroy: () => void
} => {
const handleClick = (event: MouseEvent): void => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
callbackFunction()
}
}
// event listener with capture phase to ensure it runs before other click handlers
document.addEventListener("click", handleClick, true)
return {
destroy: () => {
document.removeEventListener("click", handleClick, true)
}
}
}