feat: add library icons
This commit is contained in:
@@ -17,6 +17,7 @@ class Library(BigIntAuditBase, SlugKey):
|
||||
# Which structure to save the files in the filesystem (i.e {author_name}/{title}.{ext})
|
||||
path_template: Mapped[str]
|
||||
description: Mapped[Optional[str]]
|
||||
icon: Mapped[str] = mapped_column(default="library")
|
||||
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
books: Mapped[list["Book"]] = relationship(back_populates="library")
|
||||
|
||||
@@ -8,6 +8,7 @@ class LibraryCreate(BaseModel):
|
||||
root_path: str
|
||||
path_template: str | None = "{author}/{title}"
|
||||
description: str | None = None
|
||||
icon: str = "library"
|
||||
read_only: bool = False
|
||||
|
||||
@computed_field
|
||||
@@ -21,6 +22,7 @@ class LibraryRead(BaseModel):
|
||||
root_path: str
|
||||
path_template: str
|
||||
description: str | None
|
||||
icon: str
|
||||
read_only: bool
|
||||
total: int | None = None
|
||||
|
||||
@@ -30,4 +32,5 @@ class LibraryUpdate(BaseModel):
|
||||
root_path: str | None
|
||||
path_template: str | None
|
||||
description: str | None
|
||||
icon: str | None
|
||||
read_only: bool | None
|
||||
|
||||
@@ -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,7 +50,9 @@
|
||||
>
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<!-- Library name -->
|
||||
<!-- 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')} />
|
||||
@@ -56,6 +60,14 @@
|
||||
<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
|
||||
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} />
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user