feat: add library icons

This commit is contained in:
2026-08-10 23:58:02 -04:00
parent 0963ee85c2
commit 9fe69641c5
14 changed files with 351 additions and 17 deletions
@@ -5,13 +5,14 @@
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index';
import { Button } from '$lib/components/ui/button/index.js';
import { IconPicker } from '$lib/components/ui/icon-picker/index.js';
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { getLibraryState } from '$lib/state/library.svelte';
let { open = $bindable(false) } = $props();
let selectedIcon = $state('library');
const libraryState = getLibraryState();
</script>
@@ -35,6 +36,7 @@
const libraryName = createLibrary.fields.name.value();
form.reset();
selectedIcon = 'library';
open = false;
toast.success(`Library '${libraryName}' created.`);
@@ -48,14 +50,24 @@
>
<Field.Set>
<Field.Group>
<!-- Library name -->
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Library name and Icon -->
<div class="flex items-end gap-3">
<div class="flex-1">
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<div class="flex flex-col gap-2">
<Field.Label>Icon</Field.Label>
<IconPicker bind:value={selectedIcon} />
<input type="hidden" name="icon" value={selectedIcon} />
</div>
</div>
<!-- Description -->
<Field.Field>
@@ -4,12 +4,15 @@
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus';
import LibraryCreateForm from '../forms/library-create-form.svelte';
const libraryState = getLibraryState();
const sidebar = useSidebar();
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']);
</script>
<Sidebar.Menu>
@@ -22,11 +25,11 @@
size="lg"
class="ml-2 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{@const ActiveIcon = activeIcon.component}
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
<p class="text-lg font-semibold">{libraryState.activeLibrary!.name[0]}</p>
<!-- <libraryState?.activeLibrary.logo class="size-4" /> -->
<ActiveIcon class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="ml-1 truncate font-semibold">
@@ -45,16 +48,16 @@
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
{#each libraryState.libraries as library (library.name)}
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component}
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border">
<p>{library.name[0]}</p>
<!-- <library.logo class="size-3.5 shrink-0" /> -->
<LibraryIcon class="size-3.5 shrink-0" />
</div>
{library.name}
<Badge
<Badge
variant="outline"
class="font-semibold ml-auto">
{library.total}
{library.total ?? 0}
</Badge>
</DropdownMenu.Item>
{/each}
@@ -0,0 +1,75 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { LIBRARY_ICONS } from './library-icons.js';
let {
value = $bindable('library')
}: {
value?: string;
} = $props();
let open = $state(false);
let search = $state('');
const filteredIcons = $derived.by(() => {
if (!search) return Object.entries(LIBRARY_ICONS);
const query = search.toLowerCase();
return Object.entries(LIBRARY_ICONS).filter(
([key, icon]) =>
key.includes(query) ||
icon.label.toLowerCase().includes(query) ||
icon.category.toLowerCase().includes(query)
);
});
function selectIcon(iconKey: string) {
value = iconKey;
open = false;
search = '';
}
const selectedIcon = $derived(LIBRARY_ICONS[value] ?? LIBRARY_ICONS['library']);
const SelectedIconComponent = $derived(selectedIcon.component);
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
{#snippet child({ props })}
<Button variant="outline" size="icon" class="h-9 w-9" {...props}>
<SelectedIconComponent class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64 p-3" align="start">
<Input
bind:value={search}
placeholder="Search icons..."
class="mb-3 h-8"
/>
<ScrollArea class="h-48">
<div class="grid grid-cols-6 gap-1">
{#each filteredIcons as [key, icon] (key)}
{@const IconComponent = icon.component}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value === key ? 'bg-accent' : ''}"
onclick={() => selectIcon(key)}
>
<IconComponent class="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{icon.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</ScrollArea>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,4 @@
import IconPicker from './icon-picker.svelte';
export { LIBRARY_ICONS, getIconComponent, type IconName } from './library-icons.js';
export { IconPicker };
@@ -0,0 +1,147 @@
import {
Library,
BookOpen,
BookMarked,
Book,
Bookmark,
FolderOpen,
Code,
Terminal,
Cpu,
Server,
Database,
Binary,
Braces,
GraduationCap,
FlaskConical,
Microscope,
Atom,
Dna,
Brain,
FileText,
ScrollText,
Calculator,
Compass,
Ruler,
PencilRuler,
Sigma,
Image,
Palette,
Sparkles,
Zap,
Feather,
PenLine,
Quote,
Wand2,
Sword,
Crown,
Flame,
Shield,
Rocket,
Satellite,
Globe,
Search,
Eye,
Fingerprint,
Heart,
HeartHandshake,
Landmark,
Scroll,
Archive,
Clock,
Star,
Rainbow,
Baby,
Headphones,
Music
} from '@lucide/svelte';
import type { Component } from 'svelte';
export type IconName = keyof typeof LIBRARY_ICONS;
export const LIBRARY_ICONS: Record<string, { component: Component; label: string; category: string }> = {
// Generic
library: { component: Library, label: 'Library', category: 'Generic' },
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
'book-marked': { component: BookMarked, label: 'Book Marked', category: 'Generic' },
book: { component: Book, label: 'Book', category: 'Generic' },
bookmark: { component: Bookmark, label: 'Bookmark', category: 'Generic' },
'folder-open': { component: FolderOpen, label: 'Folder Open', category: 'Generic' },
// Technical
code: { component: Code, label: 'Code', category: 'Technical' },
terminal: { component: Terminal, label: 'Terminal', category: 'Technical' },
cpu: { component: Cpu, label: 'CPU', category: 'Technical' },
server: { component: Server, label: 'Server', category: 'Technical' },
database: { component: Database, label: 'Database', category: 'Technical' },
binary: { component: Binary, label: 'Binary', category: 'Technical' },
braces: { component: Braces, label: 'Braces', category: 'Technical' },
// Academic
'graduation-cap': { component: GraduationCap, label: 'Graduation Cap', category: 'Academic' },
'flask-conical': { component: FlaskConical, label: 'Flask', category: 'Academic' },
microscope: { component: Microscope, label: 'Microscope', category: 'Academic' },
atom: { component: Atom, label: 'Atom', category: 'Academic' },
dna: { component: Dna, label: 'DNA', category: 'Academic' },
brain: { component: Brain, label: 'Brain', category: 'Academic' },
'file-text': { component: FileText, label: 'File Text', category: 'Academic' },
'scroll-text': { component: ScrollText, label: 'Scroll Text', category: 'Academic' },
// Math/Engineering
calculator: { component: Calculator, label: 'Calculator', category: 'Math' },
compass: { component: Compass, label: 'Compass', category: 'Math' },
ruler: { component: Ruler, label: 'Ruler', category: 'Math' },
'pencil-ruler': { component: PencilRuler, label: 'Pencil Ruler', category: 'Math' },
sigma: { component: Sigma, label: 'Sigma', category: 'Math' },
// Comics/Manga
image: { component: Image, label: 'Image', category: 'Comics' },
palette: { component: Palette, label: 'Palette', category: 'Comics' },
sparkles: { component: Sparkles, label: 'Sparkles', category: 'Comics' },
zap: { component: Zap, label: 'Zap', category: 'Comics' },
// Fiction
feather: { component: Feather, label: 'Feather', category: 'Fiction' },
'pen-line': { component: PenLine, label: 'Pen', category: 'Fiction' },
quote: { component: Quote, label: 'Quote', category: 'Fiction' },
// Fantasy
wand: { component: Wand2, label: 'Wand', category: 'Fantasy' },
sword: { component: Sword, label: 'Sword', category: 'Fantasy' },
crown: { component: Crown, label: 'Crown', category: 'Fantasy' },
flame: { component: Flame, label: 'Flame', category: 'Fantasy' },
shield: { component: Shield, label: 'Shield', category: 'Fantasy' },
// Sci-Fi
rocket: { component: Rocket, label: 'Rocket', category: 'Sci-Fi' },
satellite: { component: Satellite, label: 'Satellite', category: 'Sci-Fi' },
globe: { component: Globe, label: 'Globe', category: 'Sci-Fi' },
// Mystery
search: { component: Search, label: 'Search', category: 'Mystery' },
eye: { component: Eye, label: 'Eye', category: 'Mystery' },
fingerprint: { component: Fingerprint, label: 'Fingerprint', category: 'Mystery' },
// Romance
heart: { component: Heart, label: 'Heart', category: 'Romance' },
'heart-handshake': { component: HeartHandshake, label: 'Heart Handshake', category: 'Romance' },
// History
landmark: { component: Landmark, label: 'Landmark', category: 'History' },
scroll: { component: Scroll, label: 'Scroll', category: 'History' },
archive: { component: Archive, label: 'Archive', category: 'History' },
clock: { component: Clock, label: 'Clock', category: 'History' },
// Children's
star: { component: Star, label: 'Star', category: "Children's" },
rainbow: { component: Rainbow, label: 'Rainbow', category: "Children's" },
baby: { component: Baby, label: 'Baby', category: "Children's" },
// Audio
headphones: { component: Headphones, label: 'Headphones', category: 'Audio' },
music: { component: Music, label: 'Music', category: 'Audio' }
};
export function getIconComponent(name: string): Component {
return LIBRARY_ICONS[name]?.component ?? LIBRARY_ICONS['library'].component;
}
@@ -0,0 +1,19 @@
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
+2 -1
View File
@@ -13,7 +13,8 @@ export const libraryCreateSchema = z.object({
name: z.string().min(1, 'Library must have a name'),
description: z.string().optional(),
root_path: z.string().min(1, 'Must specify a root path'),
path_template: z.string().optional().default('/{author}/{title}')
path_template: z.string().optional().default('/{author}/{title}'),
icon: z.string().default('library')
});
export type LibraryQuerySchema = typeof libraryQuerySchema;