qnote/web/src/lib/components/Sidebar.svelte

267 lines
8.1 KiB
Svelte

<script lang="ts">
import Create from "$lib/icons/sidebar/Create.svelte"
import Delete from "$lib/icons/sidebar/Delete.svelte"
import ChevronLeft from "$lib/icons/sidebar/ChevronLeft.svelte"
import ChevronRight from "$lib/icons/sidebar/ChevronRight.svelte"
import User from "$lib/icons/sidebar/User.svelte"
import ChevronDown from "$lib/icons/ChevronDown.svelte"
import ChevronUp from "$lib/icons/ChevronUp.svelte"
import Tag from "$lib/icons/sidebar/Tag.svelte"
import WebhookTruck from "$lib/icons/sidebar/WebhookTruck.svelte"
import Search from "$lib/icons/sidebar/Search.svelte"
import SettingsGear from "$lib/icons/sidebar/SettingsGear.svelte"
import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte"
import Exit from "$lib/icons/sidebar/Exit.svelte"
import ThemeToggle from "./ThemeToggle.svelte"
import { formatDateLong, formatDateShort, parseExpirationPrefix } from "$lib/util/contentVisual"
import type { FullNote, NoteMetadata } from "$lib/logic/model"
// props
export let isSidebarOpen: boolean
export let isUserMenuOpen: boolean
export let username: string
export let notes: NoteMetadata[] = []
export let currentNote: FullNote | null = null
export let toggleSettingsModal: () => void
export let toggleAdminModal: () => void
export let toggleWebhookModal: () => void
export let toggleTagModal: () => void
export let logout: () => Promise<void>
export let createNewNote: () => Promise<void>
export let selectNote: (
noteID: string,
fetchRemote: boolean,
clearSelection: boolean
) => Promise<void>
export let deleteNote: (noteID: string) => Promise<void>
const toggleSidebar = () => {
isUserMenuOpen = false
isSidebarOpen = !isSidebarOpen
}
const toggleUserMenu = () => {
isUserMenuOpen = !isUserMenuOpen
}
const handleDeleteNote = (event: MouseEvent, noteID: string) => {
event.stopPropagation()
if (confirm("Are you sure you want to delete this note?")) {
deleteNote(noteID)
}
}
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault() // prevent page scroll on space
selectNote(noteID, true, false) // sync with API due to pushing updates
}
}
// client-side search
let searchQuery = ""
$: filteredNotes = searchQuery
? notes.filter((note) => note.title.toLowerCase().includes(searchQuery.toLowerCase()))
: notes
</script>
<!-- TODO: handle pagination support later if it becomes an issue (`util/itemPagination.ts`) -->
<!-- Outmost sidebar container with collapsible functionality -->
<div
class="outer-sidebar-container"
class:sidebar-expanded={isSidebarOpen}
class:sidebar-collapsed={!isSidebarOpen}
>
<!-- Collapsed mini sidebar (always visible) -->
<div class="mini-sidebar">
<button
on:click={toggleSidebar}
class="sidebar-button"
aria-label={isSidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
>
{#if isSidebarOpen}
<ChevronLeft classString="h-5 w-5" />
{:else}
<ChevronRight classString="h-5 w-5" />
{/if}
</button>
<!-- Mini sidebar icons -->
{#if !isSidebarOpen}
<div class="mini-sidebar-button-container">
<button on:click={createNewNote} class="sidebar-button" aria-label="Create new note">
<Create classString="h-5 w-5" />
</button>
<button on:click={toggleWebhookModal} class="sidebar-button" aria-label="View webhooks">
<WebhookTruck classString="h-5 w-5" />
</button>
<button
on:click={toggleTagModal}
class="sidebar-button"
aria-label="View tags & expiration"
>
<Tag classString="h-5 w-5" />
</button>
</div>
{/if}
<!-- Should be roughly in the center of the mini sidebar despite the absolute positioning -->
<div class="absolute bottom-4 left-3">
<ThemeToggle />
</div>
</div>
<!-- Expanded sidebar -->
<aside
class="sidebar"
class:translate-x-0={isSidebarOpen}
class:translate-x-[-100%]={!isSidebarOpen}
class:opacity-100={isSidebarOpen}
class:opacity-0={!isSidebarOpen}
class:pointer-events-none={!isSidebarOpen}
class:pointer-events-auto={isSidebarOpen}
class:md:relative={true}
>
<!-- Header with app logo and display name -->
<div class="sidebar-header-container">
<!-- svelte-ignore a11y_invalid_attribute -->
<a href="#" class="sidebar-header-link" on:click={() => selectNote("", false, true)}>
<img src="favicon.svg" alt="Logo" class="sidebar-header-logo" />
<span class="sidebar-header-text">QueNote</span>
</a>
</div>
<div class="sidebar-section-divider"></div>
<!-- Action buttons (same functionality as mini sidebar icons) -->
<div class="flex flex-col px-2 py-3">
<button on:click={createNewNote} class="sidebar-action-button mb-1">
<Create classString="mr-3 h-5 w-5" />
Create note
</button>
<button on:click={toggleWebhookModal} class="sidebar-action-button mb-1">
<WebhookTruck classString="mr-3 h-5 w-5" />
Webhooks
</button>
<button on:click={toggleTagModal} class="sidebar-action-button">
<Tag classString="mr-3 h-5 w-5" />
Note tags
</button>
</div>
<div class="relative flex flex-col px-2 pb-3">
<input type="text" placeholder="Search" bind:value={searchQuery} class="sidebar-search-bar" />
<Search classString="sidebar-search-bar-icon" />
</div>
<div class="sidebar-section-divider"></div>
<!-- Notes list -->
<div class="flex-1 overflow-y-auto p-2">
{#if filteredNotes.length > 0}
<ul class="space-y-1" role="listbox">
{#each filteredNotes as note}
<li
class="sidebar-list"
class:sidebar-list-active={currentNote && note.id === currentNote.id}
on:click={() => selectNote(note.id, false, false)}
on:keydown={(e) => handleNoteKeydown(e, note.id)}
tabindex="0"
role="option"
aria-selected={currentNote && note.id === currentNote.id}
>
<div class="flex w-full items-start justify-between">
<h3 class="sidebar-list-item-title">
{parseExpirationPrefix(note.title)[0] || "Untitled Note"}
</h3>
<!-- Delete button -->
<button
on:click={(e) => handleDeleteNote(e, note.id)}
class="sidebar-button sidebar-list-item-delete-button"
aria-label="Delete note"
>
<Delete classString="h-3 w-3" />
</button>
</div>
<p class="sidebar-list-item-metadata">
{formatDateLong(note.updatedAt)}
</p>
<p class="sidebar-list-item-metadata">
Expires {note.expiresAt !== null ? formatDateShort(note.expiresAt) : "never"}
</p>
</li>
{/each}
</ul>
{:else}
<div class="sidebar-search-info">
{searchQuery ? "No notes match your search" : "No notes yet"}
</div>
{/if}
</div>
<div class="sidebar-section-divider"></div>
<!-- User section (single button) with dropdown -->
<div class="relative flex flex-col px-2 py-3">
<button
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">
<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>
</button>
<!-- User actions dropdown menu -->
{#if isUserMenuOpen}
<div class="sidebar-user-dropdown">
<div class="flex flex-col p-0">
<button
on:click={toggleSettingsModal}
class="sidebar-action-button rounded-none rounded-t-lg border-0 border-b"
>
<SettingsGear classString="mr-3 h-5 w-5" />
Settings
</button>
<button
on:click={toggleAdminModal}
class="sidebar-action-button rounded-none border-0 border-y"
>
<AdminWrench classString="mr-3 h-5 w-5" />
Admin view
</button>
<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>
{/if}
</div>
</aside>
</div>