Merge pull request 'feature/builtin-themes' (#4) from feature/builtin-themes into master
Reviewed-on: #4
58
drizzle/relations.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { relations } from "drizzle-orm/relations";
|
||||||
|
import { wishlists, items, user, savedWishlists, reservations, session, account } from "./schema";
|
||||||
|
|
||||||
|
export const itemsRelations = relations(items, ({one, many}) => ({
|
||||||
|
wishlist: one(wishlists, {
|
||||||
|
fields: [items.wishlistId],
|
||||||
|
references: [wishlists.id]
|
||||||
|
}),
|
||||||
|
reservations: many(reservations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const wishlistsRelations = relations(wishlists, ({one, many}) => ({
|
||||||
|
items: many(items),
|
||||||
|
user: one(user, {
|
||||||
|
fields: [wishlists.userId],
|
||||||
|
references: [user.id]
|
||||||
|
}),
|
||||||
|
savedWishlists: many(savedWishlists),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userRelations = relations(user, ({many}) => ({
|
||||||
|
wishlists: many(wishlists),
|
||||||
|
savedWishlists: many(savedWishlists),
|
||||||
|
sessions: many(session),
|
||||||
|
accounts: many(account),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const savedWishlistsRelations = relations(savedWishlists, ({one}) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [savedWishlists.userId],
|
||||||
|
references: [user.id]
|
||||||
|
}),
|
||||||
|
wishlist: one(wishlists, {
|
||||||
|
fields: [savedWishlists.wishlistId],
|
||||||
|
references: [wishlists.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const reservationsRelations = relations(reservations, ({one}) => ({
|
||||||
|
item: one(items, {
|
||||||
|
fields: [reservations.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sessionRelations = relations(session, ({one}) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [session.userId],
|
||||||
|
references: [user.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const accountRelations = relations(account, ({one}) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [account.userId],
|
||||||
|
references: [user.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
137
drizzle/schema.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { pgTable, foreignKey, text, numeric, boolean, timestamp, unique, primaryKey } from "drizzle-orm/pg-core"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const items = pgTable("items", {
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
wishlistId: text("wishlist_id").notNull(),
|
||||||
|
title: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
link: text(),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
price: numeric({ precision: 10, scale: 2 }),
|
||||||
|
currency: text().default('DKK'),
|
||||||
|
color: text(),
|
||||||
|
order: numeric().default('0').notNull(),
|
||||||
|
isReserved: boolean("is_reserved").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.wishlistId],
|
||||||
|
foreignColumns: [wishlists.id],
|
||||||
|
name: "items_wishlist_id_wishlists_id_fk"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const wishlists = pgTable("wishlists", {
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
userId: text("user_id"),
|
||||||
|
title: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
ownerToken: text("owner_token").notNull(),
|
||||||
|
publicToken: text("public_token").notNull(),
|
||||||
|
isFavorite: boolean("is_favorite").default(false).notNull(),
|
||||||
|
color: text(),
|
||||||
|
endDate: timestamp("end_date", { mode: 'string' }),
|
||||||
|
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
theme: text().default('none'),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [user.id],
|
||||||
|
name: "wishlists_user_id_user_id_fk"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
unique("wishlists_owner_token_unique").on(table.ownerToken),
|
||||||
|
unique("wishlists_public_token_unique").on(table.publicToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const savedWishlists = pgTable("saved_wishlists", {
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
wishlistId: text("wishlist_id").notNull(),
|
||||||
|
isFavorite: boolean("is_favorite").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
ownerToken: text("owner_token"),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [user.id],
|
||||||
|
name: "saved_wishlists_user_id_user_id_fk"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.wishlistId],
|
||||||
|
foreignColumns: [wishlists.id],
|
||||||
|
name: "saved_wishlists_wishlist_id_wishlists_id_fk"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const user = pgTable("user", {
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
name: text(),
|
||||||
|
email: text(),
|
||||||
|
emailVerified: timestamp({ mode: 'string' }),
|
||||||
|
image: text(),
|
||||||
|
password: text(),
|
||||||
|
username: text(),
|
||||||
|
dashboardTheme: text("dashboard_theme").default('none'),
|
||||||
|
}, (table) => [
|
||||||
|
unique("user_email_unique").on(table.email),
|
||||||
|
unique("user_username_unique").on(table.username),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const reservations = pgTable("reservations", {
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
itemId: text("item_id").notNull(),
|
||||||
|
reserverName: text("reserver_name"),
|
||||||
|
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "reservations_item_id_items_id_fk"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const session = pgTable("session", {
|
||||||
|
sessionToken: text().primaryKey().notNull(),
|
||||||
|
userId: text().notNull(),
|
||||||
|
expires: timestamp({ mode: 'string' }).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [user.id],
|
||||||
|
name: "session_userId_user_id_fk"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const verificationToken = pgTable("verificationToken", {
|
||||||
|
identifier: text().notNull(),
|
||||||
|
token: text().notNull(),
|
||||||
|
expires: timestamp({ mode: 'string' }).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const account = pgTable("account", {
|
||||||
|
userId: text().notNull(),
|
||||||
|
type: text().notNull(),
|
||||||
|
provider: text().notNull(),
|
||||||
|
providerAccountId: text().notNull(),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
expiresAt: numeric("expires_at"),
|
||||||
|
tokenType: text("token_type"),
|
||||||
|
scope: text(),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
sessionState: text("session_state"),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [user.id],
|
||||||
|
name: "account_userId_user_id_fk"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
|
||||||
|
]);
|
||||||
@@ -15,43 +15,106 @@
|
|||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
let localWishlists = $state<LocalWishlist[]>([]);
|
let localWishlists = $state<LocalWishlist[]>([]);
|
||||||
|
let enrichedWishlists = $state<any[]>([]);
|
||||||
|
|
||||||
// Load local wishlists on mount (client-side only)
|
// Load local wishlists on mount and fetch their data from server
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
localWishlists = getLocalWishlists();
|
localWishlists = getLocalWishlists();
|
||||||
|
|
||||||
|
// Fetch full wishlist data for each local wishlist
|
||||||
|
const promises = localWishlists.map(async (local) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
isFavorite: local.isFavorite || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch wishlist data:', error);
|
||||||
|
}
|
||||||
|
// Fallback to local data if fetch fails
|
||||||
|
return {
|
||||||
|
id: local.ownerToken,
|
||||||
|
title: local.title,
|
||||||
|
ownerToken: local.ownerToken,
|
||||||
|
publicToken: local.publicToken,
|
||||||
|
createdAt: local.createdAt,
|
||||||
|
isFavorite: local.isFavorite || false,
|
||||||
|
items: [],
|
||||||
|
theme: null,
|
||||||
|
color: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
enrichedWishlists = await Promise.all(promises);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleForget(ownerToken: string) {
|
async function refreshEnrichedWishlists() {
|
||||||
|
const promises = localWishlists.map(async (local) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
isFavorite: local.isFavorite || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch wishlist data:', error);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: local.ownerToken,
|
||||||
|
title: local.title,
|
||||||
|
ownerToken: local.ownerToken,
|
||||||
|
publicToken: local.publicToken,
|
||||||
|
createdAt: local.createdAt,
|
||||||
|
isFavorite: local.isFavorite || false,
|
||||||
|
items: [],
|
||||||
|
theme: null,
|
||||||
|
color: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
enrichedWishlists = await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForget(ownerToken: string) {
|
||||||
forgetLocalWishlist(ownerToken);
|
forgetLocalWishlist(ownerToken);
|
||||||
localWishlists = getLocalWishlists();
|
localWishlists = getLocalWishlists();
|
||||||
|
await refreshEnrichedWishlists();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleFavorite(ownerToken: string) {
|
async function handleToggleFavorite(ownerToken: string) {
|
||||||
toggleLocalFavorite(ownerToken);
|
toggleLocalFavorite(ownerToken);
|
||||||
localWishlists = getLocalWishlists();
|
localWishlists = getLocalWishlists();
|
||||||
|
await refreshEnrichedWishlists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform LocalWishlist to match the format expected by WishlistSection
|
// Use enriched wishlists which have full data including theme and color
|
||||||
const transformedWishlists = $derived(() => {
|
const transformedWishlists = $derived(() => enrichedWishlists);
|
||||||
return localWishlists.map(w => ({
|
|
||||||
id: w.ownerToken,
|
// Description depends on authentication status
|
||||||
title: w.title,
|
const sectionDescription = $derived(() => {
|
||||||
ownerToken: w.ownerToken,
|
if (isAuthenticated) {
|
||||||
publicToken: w.publicToken,
|
return t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet.";
|
||||||
createdAt: w.createdAt,
|
}
|
||||||
isFavorite: w.isFavorite || false,
|
return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently.";
|
||||||
items: [] // We don't have item data in localStorage
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if localWishlists.length > 0}
|
<WishlistSection
|
||||||
<WishlistSection
|
title={t.dashboard.localWishlists || "Local Wishlists"}
|
||||||
title={t.dashboard.localWishlists || "Local Wishlists"}
|
description={sectionDescription()}
|
||||||
description={t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently."}
|
items={transformedWishlists()}
|
||||||
items={transformedWishlists()}
|
emptyMessage={t.dashboard.emptyLocalWishlists || "No local wishlists yet"}
|
||||||
emptyMessage=""
|
emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
|
||||||
>
|
emptyActionHref="/"
|
||||||
|
showCreateButton={true}
|
||||||
|
>
|
||||||
{#snippet actions(wishlist, unlocked)}
|
{#snippet actions(wishlist, unlocked)}
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
@@ -89,5 +152,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</WishlistSection>
|
</WishlistSection>
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -3,26 +3,30 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
itemCount,
|
itemCount,
|
||||||
color = null,
|
color = null,
|
||||||
|
theme = null,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
theme?: string | null;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const cardStyle = $derived(getCardStyle(color));
|
const cardStyle = $derived(getCardStyle(color));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle} class="h-full flex flex-col">
|
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
|
||||||
<CardHeader class="flex-shrink-0">
|
<ThemeCard themeName={theme} color={color} />
|
||||||
|
<CardHeader class="flex-shrink-0 relative z-10">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
|
||||||
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
|
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span class="truncate">{title}</span>
|
<span class="truncate">{title}</span>
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
|
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2 flex-1 flex flex-col justify-end">
|
<CardContent class="space-y-2 flex-1 flex flex-col justify-end relative z-10">
|
||||||
{#if children}
|
{#if children}
|
||||||
<div>
|
<div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
description={getWishlistDescription(item)}
|
description={getWishlistDescription(item)}
|
||||||
itemCount={wishlist.items?.length || 0}
|
itemCount={wishlist.items?.length || 0}
|
||||||
color={wishlist.color}
|
color={wishlist.color}
|
||||||
|
theme={wishlist.theme}
|
||||||
>
|
>
|
||||||
{@render actions(item, unlocked)}
|
{@render actions(item, unlocked)}
|
||||||
</WishlistCard>
|
</WishlistCard>
|
||||||
|
|||||||
@@ -2,13 +2,44 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
|
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
|
||||||
import { signOut } from '@auth/sveltekit/client';
|
import { signOut } from '@auth/sveltekit/client';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
let { userName, userEmail }: { userName?: string | null; userEmail?: string | null } = $props();
|
let {
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
dashboardTheme = 'none',
|
||||||
|
isAuthenticated = false,
|
||||||
|
onThemeUpdate
|
||||||
|
}: {
|
||||||
|
userName?: string | null;
|
||||||
|
userEmail?: string | null;
|
||||||
|
dashboardTheme?: string;
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
onThemeUpdate?: (theme: string | null) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
const isAuthenticated = $derived(!!userName || !!userEmail);
|
|
||||||
|
async function handleThemeChange(theme: string) {
|
||||||
|
// Update theme immediately for instant visual feedback
|
||||||
|
if (onThemeUpdate) {
|
||||||
|
onThemeUpdate(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only submit to database for authenticated users
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('theme', theme);
|
||||||
|
|
||||||
|
await fetch('?/updateDashboardTheme', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -21,6 +52,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||||
|
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} />
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{#if isAuthenticated}
|
{#if isAuthenticated}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
|
||||||
|
|
||||||
let { children, maxWidth = '6xl' }: { children: Snippet; maxWidth?: string } = $props();
|
let {
|
||||||
|
children,
|
||||||
|
maxWidth = '6xl',
|
||||||
|
theme = null,
|
||||||
|
themeColor = null
|
||||||
|
}: {
|
||||||
|
children: Snippet;
|
||||||
|
maxWidth?: string;
|
||||||
|
theme?: string | null;
|
||||||
|
themeColor?: string | null;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen p-4 md:p-8">
|
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden">
|
||||||
<div class="max-w-{maxWidth} mx-auto space-y-6">
|
<ThemeBackground themeName={theme} color={themeColor} />
|
||||||
|
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
src/lib/components/themes/ThemeBackground.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TopPattern from './svgs/TopPattern.svelte';
|
||||||
|
import BottomPattern from './svgs/BottomPattern.svelte';
|
||||||
|
import { getTheme, getPatternColor, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||||
|
|
||||||
|
let {
|
||||||
|
themeName,
|
||||||
|
showTop = true,
|
||||||
|
showBottom = true,
|
||||||
|
color
|
||||||
|
}: {
|
||||||
|
themeName?: string | null;
|
||||||
|
showTop?: boolean;
|
||||||
|
showBottom?: boolean;
|
||||||
|
color?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const theme = $derived(getTheme(themeName));
|
||||||
|
const patternColor = $derived(getPatternColor(color));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if theme.pattern !== 'none'}
|
||||||
|
{#if showTop}
|
||||||
|
<TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||||
|
{/if}
|
||||||
|
{#if showBottom}
|
||||||
|
<BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
19
src/lib/components/themes/ThemeCard.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CardPattern from './svgs/CardPattern.svelte';
|
||||||
|
import { getTheme, getPatternColor, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||||
|
|
||||||
|
let {
|
||||||
|
themeName,
|
||||||
|
color
|
||||||
|
}: {
|
||||||
|
themeName?: string;
|
||||||
|
color?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const theme = $derived(getTheme(themeName));
|
||||||
|
const patternColor = $derived(getPatternColor(color));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if theme.pattern !== 'none'}
|
||||||
|
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||||
|
{/if}
|
||||||
32
src/lib/components/themes/svgs/BottomPattern.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { asset } from '$app/paths';
|
||||||
|
|
||||||
|
let {
|
||||||
|
pattern = 'none',
|
||||||
|
color = '#000000',
|
||||||
|
opacity = 0.1
|
||||||
|
}: {
|
||||||
|
pattern?: string;
|
||||||
|
color?: string;
|
||||||
|
opacity?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pattern !== 'none'}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 pointer-events-none overflow-hidden"
|
||||||
|
style="
|
||||||
|
mask-image: url({patternPath});
|
||||||
|
-webkit-mask-image: url({patternPath});
|
||||||
|
mask-size: cover;
|
||||||
|
-webkit-mask-size: cover;
|
||||||
|
mask-repeat: repeat;
|
||||||
|
-webkit-mask-repeat: repeat;
|
||||||
|
background-color: {color};
|
||||||
|
opacity: {opacity};
|
||||||
|
height: 200px;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
33
src/lib/components/themes/svgs/CardPattern.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { asset } from '$app/paths';
|
||||||
|
|
||||||
|
let {
|
||||||
|
pattern = 'none',
|
||||||
|
color = '#000000',
|
||||||
|
opacity = 0.1
|
||||||
|
}: {
|
||||||
|
pattern?: string;
|
||||||
|
color?: string;
|
||||||
|
opacity?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const patternPath = $derived(asset(`/themes/${pattern}/item.svg`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pattern !== 'none'}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
|
||||||
|
style="
|
||||||
|
mask-image: url({patternPath});
|
||||||
|
-webkit-mask-image: url({patternPath});
|
||||||
|
mask-size: cover;
|
||||||
|
-webkit-mask-size: cover;
|
||||||
|
mask-repeat: repeat;
|
||||||
|
-webkit-mask-repeat: repeat;
|
||||||
|
background-color: {color};
|
||||||
|
opacity: {opacity};
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
32
src/lib/components/themes/svgs/TopPattern.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { asset } from '$app/paths';
|
||||||
|
|
||||||
|
let {
|
||||||
|
pattern = 'none',
|
||||||
|
color = '#000000',
|
||||||
|
opacity = 0.1
|
||||||
|
}: {
|
||||||
|
pattern?: string;
|
||||||
|
color?: string;
|
||||||
|
opacity?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pattern !== 'none'}
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 right-0 pointer-events-none overflow-hidden"
|
||||||
|
style="
|
||||||
|
mask-image: url({patternPath});
|
||||||
|
-webkit-mask-image: url({patternPath});
|
||||||
|
mask-size: cover;
|
||||||
|
-webkit-mask-size: cover;
|
||||||
|
mask-repeat: repeat;
|
||||||
|
-webkit-mask-repeat: repeat;
|
||||||
|
background-color: {color};
|
||||||
|
opacity: {opacity};
|
||||||
|
height: 200px;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
68
src/lib/components/ui/theme-picker.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Palette } from 'lucide-svelte';
|
||||||
|
import { AVAILABLE_THEMES } from '$lib/utils/themes';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = 'none',
|
||||||
|
onValueChange
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onValueChange: (theme: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let showMenu = $state(false);
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
showMenu = !showMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(themeName: string) {
|
||||||
|
onValueChange(themeName);
|
||||||
|
showMenu = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.theme-picker-menu')) {
|
||||||
|
showMenu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showMenu) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative theme-picker-menu">
|
||||||
|
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Select theme pattern">
|
||||||
|
<Palette class="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if showMenu}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 shadow-lg z-50"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
{#each Object.entries(AVAILABLE_THEMES) as [key, theme]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors flex items-center justify-between"
|
||||||
|
class:font-bold={value === key}
|
||||||
|
class:bg-slate-100={value === key}
|
||||||
|
class:dark:bg-slate-900={value === key}
|
||||||
|
onclick={() => handleSelect(key)}
|
||||||
|
>
|
||||||
|
<span>{theme.name}</span>
|
||||||
|
{#if value === key}
|
||||||
|
<span class="ml-2">✓</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -12,12 +12,14 @@
|
|||||||
items = $bindable([]),
|
items = $bindable([]),
|
||||||
rearranging,
|
rearranging,
|
||||||
onStartEditing,
|
onStartEditing,
|
||||||
onReorder
|
onReorder,
|
||||||
|
theme = null
|
||||||
}: {
|
}: {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
rearranging: boolean;
|
rearranging: boolean;
|
||||||
onStartEditing: (item: Item) => void;
|
onStartEditing: (item: Item) => void;
|
||||||
onReorder: (items: Item[]) => Promise<void>;
|
onReorder: (items: Item[]) => Promise<void>;
|
||||||
|
theme?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<div animate:flip={{ duration: 300 }}>
|
<div animate:flip={{ duration: 300 }}>
|
||||||
<WishlistItem {item} showDragHandle={false}>
|
<WishlistItem {item} {theme} showDragHandle={false}>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,28 +7,81 @@
|
|||||||
itemId: string;
|
itemId: string;
|
||||||
isReserved: boolean;
|
isReserved: boolean;
|
||||||
reserverName?: string | null;
|
reserverName?: string | null;
|
||||||
|
reservationUserId?: string | null;
|
||||||
|
currentUserId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { itemId, isReserved, reserverName }: Props = $props();
|
let { itemId, isReserved, reserverName, reservationUserId, currentUserId }: Props = $props();
|
||||||
|
|
||||||
let showReserveForm = $state(false);
|
let showReserveForm = $state(false);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
|
let showCancelConfirmation = $state(false);
|
||||||
|
|
||||||
|
const canCancel = $derived(() => {
|
||||||
|
if (!isReserved) return false;
|
||||||
|
if (reservationUserId) {
|
||||||
|
return currentUserId === reservationUserId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAnonymousReservation = $derived(!reservationUserId);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isReserved}
|
{#if isReserved}
|
||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-start gap-2">
|
||||||
<div class="text-sm text-green-600 font-medium">
|
<div class="text-sm text-green-600 font-medium">
|
||||||
✓ Reserved
|
✓ Reserved
|
||||||
{#if reserverName}
|
{#if reserverName}
|
||||||
by {reserverName}
|
by {reserverName}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="?/unreserve" use:enhance>
|
{#if canCancel()}
|
||||||
<input type="hidden" name="itemId" value={itemId} />
|
{#if showCancelConfirmation}
|
||||||
<Button type="submit" variant="outline" size="sm">
|
<div class="flex flex-col gap-2 items-start">
|
||||||
Cancel Reservation
|
<p class="text-sm text-muted-foreground">
|
||||||
</Button>
|
Cancel this reservation?
|
||||||
</form>
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<form method="POST" action="?/unreserve" use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
showCancelConfirmation = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
Yes, Cancel
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (showCancelConfirmation = false)}
|
||||||
|
>
|
||||||
|
No, Keep It
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if isAnonymousReservation}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (showCancelConfirmation = true)}
|
||||||
|
>
|
||||||
|
Cancel Reservation
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" action="?/unreserve" use:enhance>
|
||||||
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Cancel Reservation
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if showReserveForm}
|
{:else if showReserveForm}
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
import { Pencil, Check, X } from "lucide-svelte";
|
import { Pencil, Check, X } from "lucide-svelte";
|
||||||
import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
|
import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
|
||||||
|
import ThemePicker from "$lib/components/ui/theme-picker.svelte";
|
||||||
import type { Wishlist } from "$lib/server/schema";
|
import type { Wishlist } from "$lib/server/schema";
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
@@ -13,13 +14,15 @@
|
|||||||
onTitleUpdate,
|
onTitleUpdate,
|
||||||
onDescriptionUpdate,
|
onDescriptionUpdate,
|
||||||
onColorUpdate,
|
onColorUpdate,
|
||||||
onEndDateUpdate
|
onEndDateUpdate,
|
||||||
|
onThemeUpdate
|
||||||
}: {
|
}: {
|
||||||
wishlist: Wishlist;
|
wishlist: Wishlist;
|
||||||
onTitleUpdate: (title: string) => Promise<boolean>;
|
onTitleUpdate: (title: string) => Promise<boolean>;
|
||||||
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
|
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
|
||||||
onColorUpdate: (color: string | null) => void;
|
onColorUpdate: (color: string | null) => void;
|
||||||
onEndDateUpdate: (endDate: string | null) => void;
|
onEndDateUpdate: (endDate: string | null) => void;
|
||||||
|
onThemeUpdate: (theme: string | null) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
let wishlistTitle = $state(wishlist.title);
|
let wishlistTitle = $state(wishlist.title);
|
||||||
let wishlistDescription = $state(wishlist.description || "");
|
let wishlistDescription = $state(wishlist.description || "");
|
||||||
let wishlistColor = $state<string | null>(wishlist.color);
|
let wishlistColor = $state<string | null>(wishlist.color);
|
||||||
|
let wishlistTheme = $state<string>(wishlist.theme || 'none');
|
||||||
let wishlistEndDate = $state<string | null>(
|
let wishlistEndDate = $state<string | null>(
|
||||||
wishlist.endDate
|
wishlist.endDate
|
||||||
? new Date(wishlist.endDate).toISOString().split("T")[0]
|
? new Date(wishlist.endDate).toISOString().split("T")[0]
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
editingTitle = true;
|
editingTitle = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||||
aria-label={editingTitle ? "Save title" : "Edit title"}
|
aria-label={editingTitle ? "Save title" : "Edit title"}
|
||||||
>
|
>
|
||||||
{#if editingTitle}
|
{#if editingTitle}
|
||||||
@@ -112,7 +116,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<ThemePicker
|
||||||
|
value={wishlistTheme}
|
||||||
|
onValueChange={async (theme) => {
|
||||||
|
wishlistTheme = theme;
|
||||||
|
onThemeUpdate(theme);
|
||||||
|
// Force reactivity by updating the wishlist object
|
||||||
|
wishlist.theme = theme;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
bind:color={wishlistColor}
|
bind:color={wishlistColor}
|
||||||
onchange={() => onColorUpdate(wishlistColor)}
|
onchange={() => onColorUpdate(wishlistColor)}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
import { getCardStyle } from '$lib/utils/colors';
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: Item;
|
item: Item;
|
||||||
showImage?: boolean;
|
showImage?: boolean;
|
||||||
children?: any;
|
children?: any;
|
||||||
showDragHandle?: boolean;
|
showDragHandle?: boolean;
|
||||||
|
theme?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
showImage = true,
|
showImage = true,
|
||||||
children,
|
children,
|
||||||
showDragHandle = false,
|
showDragHandle = false,
|
||||||
|
theme = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
@@ -51,8 +54,9 @@
|
|||||||
const cardStyle = $derived(getCardStyle(item.color));
|
const cardStyle = $derived(getCardStyle(item.color));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle}>
|
<Card style={cardStyle} class="relative overflow-hidden">
|
||||||
<CardContent class="p-6">
|
<ThemeCard themeName={theme} color={item.color} />
|
||||||
|
<CardContent class="p-6 relative z-10">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
{#if showDragHandle}
|
{#if showDragHandle}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export const users = pgTable('user', {
|
|||||||
emailVerified: timestamp('emailVerified', { mode: 'date' }),
|
emailVerified: timestamp('emailVerified', { mode: 'date' }),
|
||||||
image: text('image'),
|
image: text('image'),
|
||||||
password: text('password'),
|
password: text('password'),
|
||||||
username: text('username').unique()
|
username: text('username').unique(),
|
||||||
|
dashboardTheme: text('dashboard_theme').default('none')
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = pgTable(
|
export const accounts = pgTable(
|
||||||
@@ -70,6 +71,7 @@ export const wishlists = pgTable('wishlists', {
|
|||||||
publicToken: text('public_token').notNull().unique(),
|
publicToken: text('public_token').notNull().unique(),
|
||||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||||
color: text('color'),
|
color: text('color'),
|
||||||
|
theme: text('theme').default('none'),
|
||||||
endDate: timestamp('end_date', { mode: 'date' }),
|
endDate: timestamp('end_date', { mode: 'date' }),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||||
@@ -115,6 +117,7 @@ export const reservations = pgTable('reservations', {
|
|||||||
itemId: text('item_id')
|
itemId: text('item_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => items.id, { onDelete: 'cascade' }),
|
.references(() => items.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
reserverName: text('reserver_name'),
|
reserverName: text('reserver_name'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull()
|
createdAt: timestamp('created_at').defaultNow().notNull()
|
||||||
});
|
});
|
||||||
@@ -123,6 +126,10 @@ export const reservationsRelations = relations(reservations, ({ one }) => ({
|
|||||||
item: one(items, {
|
item: one(items, {
|
||||||
fields: [reservations.itemId],
|
fields: [reservations.itemId],
|
||||||
references: [items.id]
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [reservations.userId],
|
||||||
|
references: [users.id]
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -152,7 +159,8 @@ export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
|
|||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
wishlists: many(wishlists),
|
wishlists: many(wishlists),
|
||||||
savedWishlists: many(savedWishlists)
|
savedWishlists: many(savedWishlists),
|
||||||
|
reservations: many(reservations)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
class ThemeStore {
|
class ThemeStore {
|
||||||
current = $state<Theme>('system');
|
current = $state<Theme>('system');
|
||||||
@@ -11,10 +12,8 @@ class ThemeStore {
|
|||||||
this.current = stored || 'system';
|
this.current = stored || 'system';
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
mediaQuery.addEventListener('change', () => {
|
mediaQuery.addEventListener('change', () => {
|
||||||
// Re-apply theme if in system mode
|
|
||||||
if (this.current === 'system') {
|
if (this.current === 'system') {
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
}
|
}
|
||||||
@@ -35,6 +34,14 @@ class ThemeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getResolvedTheme(): ResolvedTheme {
|
||||||
|
if (!browser) return 'light';
|
||||||
|
|
||||||
|
const isDark = this.current === 'dark' ||
|
||||||
|
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
return isDark ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
// Cycle through: light -> dark -> system -> light
|
// Cycle through: light -> dark -> system -> light
|
||||||
if (this.current === 'light') {
|
if (this.current === 'light') {
|
||||||
|
|||||||
41
src/lib/utils/themes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
|
|
||||||
|
export type ThemePattern = 'waves' | 'geometric' | 'dots' | 'none';
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
name: string;
|
||||||
|
pattern: ThemePattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVAILABLE_THEMES: Record<string, Theme> = {
|
||||||
|
none: {
|
||||||
|
name: 'None',
|
||||||
|
pattern: 'none'
|
||||||
|
},
|
||||||
|
waves: {
|
||||||
|
name: 'Waves',
|
||||||
|
pattern: 'waves'
|
||||||
|
},
|
||||||
|
geometric: {
|
||||||
|
name: 'Geometric',
|
||||||
|
pattern: 'geometric'
|
||||||
|
},
|
||||||
|
dots: {
|
||||||
|
name: 'Dots',
|
||||||
|
pattern: 'dots'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_THEME = 'none';
|
||||||
|
export const PATTERN_OPACITY = 0.1;
|
||||||
|
|
||||||
|
export function getTheme(themeName?: string | null): Theme {
|
||||||
|
if (!themeName || !AVAILABLE_THEMES[themeName]) {
|
||||||
|
return AVAILABLE_THEMES[DEFAULT_THEME];
|
||||||
|
}
|
||||||
|
return AVAILABLE_THEMES[themeName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPatternColor(customColor?: string): string {
|
||||||
|
return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000');
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ export async function updateEndDate(endDate: string | null): Promise<boolean> {
|
|||||||
return updateWishlist({ endDate: endDate || "" });
|
return updateWishlist({ endDate: endDate || "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTheme(theme: string | null): Promise<boolean> {
|
||||||
|
return updateWishlist({ theme: theme || "none" });
|
||||||
|
}
|
||||||
|
|
||||||
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
|
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
|
||||||
const response = await fetch("?/reorderItems", {
|
const response = await fetch("?/reorderItems", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
38
src/routes/api/wishlist/[token]/+server.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { wishlists } from '$lib/server/schema';
|
||||||
|
import { eq, or } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const { token } = params;
|
||||||
|
|
||||||
|
// Find wishlist by either ownerToken or publicToken
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: or(
|
||||||
|
eq(wishlists.ownerToken, token),
|
||||||
|
eq(wishlists.publicToken, token)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
return json({ error: 'Wishlist not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only the necessary fields
|
||||||
|
return json({
|
||||||
|
id: wishlist.id,
|
||||||
|
title: wishlist.title,
|
||||||
|
ownerToken: wishlist.ownerToken,
|
||||||
|
publicToken: wishlist.publicToken,
|
||||||
|
createdAt: wishlist.createdAt,
|
||||||
|
theme: wishlist.theme,
|
||||||
|
color: wishlist.color,
|
||||||
|
items: wishlist.items || []
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { wishlists, savedWishlists } from '$lib/server/schema';
|
import { wishlists, savedWishlists, users } from '$lib/server/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
@@ -17,6 +17,11 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user with theme
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, session.user.id)
|
||||||
|
});
|
||||||
|
|
||||||
const userWishlists = await db.query.wishlists.findMany({
|
const userWishlists = await db.query.wishlists.findMany({
|
||||||
where: eq(wishlists.userId, session.user.id),
|
where: eq(wishlists.userId, session.user.id),
|
||||||
with: {
|
with: {
|
||||||
@@ -57,7 +62,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: session.user,
|
user: user,
|
||||||
wishlists: userWishlists,
|
wishlists: userWishlists,
|
||||||
savedWishlists: savedWithAccess,
|
savedWishlists: savedWithAccess,
|
||||||
isAuthenticated: true
|
isAuthenticated: true
|
||||||
@@ -149,6 +154,26 @@ export const actions: Actions = {
|
|||||||
eq(wishlists.userId, session.user.id)
|
eq(wishlists.userId, session.user.id)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateDashboardTheme: async ({ request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw redirect(303, '/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const theme = formData.get('theme') as string;
|
||||||
|
|
||||||
|
if (!theme) {
|
||||||
|
return { success: false, error: 'Theme is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set({ dashboardTheme: theme })
|
||||||
|
.where(eq(users.id, session.user.id));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,30 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// For anonymous users, get theme from localStorage
|
||||||
|
function getInitialTheme() {
|
||||||
|
if (data.isAuthenticated) {
|
||||||
|
return data.user?.dashboardTheme || 'none';
|
||||||
|
} else {
|
||||||
|
// Anonymous user - get from localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('dashboardTheme') || 'none';
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTheme = $state(getInitialTheme());
|
||||||
|
|
||||||
|
// Save to localStorage when theme changes for anonymous users
|
||||||
|
function handleThemeUpdate(theme: string | null) {
|
||||||
|
currentTheme = theme || 'none';
|
||||||
|
|
||||||
|
if (!data.isAuthenticated && typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('dashboardTheme', currentTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
// Only owned wishlists for "My Wishlists"
|
// Only owned wishlists for "My Wishlists"
|
||||||
@@ -34,8 +58,14 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageContainer>
|
<PageContainer theme={currentTheme} themeColor={null}>
|
||||||
<DashboardHeader userName={data.user?.name} userEmail={data.user?.email} />
|
<DashboardHeader
|
||||||
|
userName={data.user?.name}
|
||||||
|
userEmail={data.user?.email}
|
||||||
|
dashboardTheme={currentTheme}
|
||||||
|
isAuthenticated={data.isAuthenticated}
|
||||||
|
onThemeUpdate={handleThemeUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
|
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
|
||||||
<LocalWishlistsSection isAuthenticated={data.isAuthenticated} />
|
<LocalWishlistsSection isAuthenticated={data.isAuthenticated} />
|
||||||
|
|||||||
@@ -43,12 +43,13 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
isSaved,
|
isSaved,
|
||||||
isClaimed,
|
isClaimed,
|
||||||
savedWishlistId,
|
savedWishlistId,
|
||||||
isAuthenticated: !!session?.user
|
isAuthenticated: !!session?.user,
|
||||||
|
currentUserId: session?.user?.id || null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
reserve: async ({ request }) => {
|
reserve: async ({ request, locals }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const itemId = formData.get('itemId') as string;
|
const itemId = formData.get('itemId') as string;
|
||||||
const reserverName = formData.get('reserverName') as string;
|
const reserverName = formData.get('reserverName') as string;
|
||||||
@@ -57,6 +58,8 @@ export const actions: Actions = {
|
|||||||
return { success: false, error: 'Item ID is required' };
|
return { success: false, error: 'Item ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
|
||||||
const existingReservation = await db.query.reservations.findFirst({
|
const existingReservation = await db.query.reservations.findFirst({
|
||||||
where: eq(reservations.itemId, itemId)
|
where: eq(reservations.itemId, itemId)
|
||||||
});
|
});
|
||||||
@@ -68,6 +71,7 @@ export const actions: Actions = {
|
|||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.insert(reservations).values({
|
await tx.insert(reservations).values({
|
||||||
itemId,
|
itemId,
|
||||||
|
userId: session?.user?.id || null,
|
||||||
reserverName: reserverName?.trim() || null
|
reserverName: reserverName?.trim() || null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,7 +84,7 @@ export const actions: Actions = {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
unreserve: async ({ request }) => {
|
unreserve: async ({ request, locals }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const itemId = formData.get('itemId') as string;
|
const itemId = formData.get('itemId') as string;
|
||||||
|
|
||||||
@@ -88,6 +92,25 @@ export const actions: Actions = {
|
|||||||
return { success: false, error: 'Item ID is required' };
|
return { success: false, error: 'Item ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
|
||||||
|
const reservation = await db.query.reservations.findFirst({
|
||||||
|
where: eq(reservations.itemId, itemId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reservation) {
|
||||||
|
return { success: false, error: 'Reservation not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reservation.userId) {
|
||||||
|
if (!session?.user?.id || session.user.id !== reservation.userId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'You can only cancel your own reservations'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageContainer maxWidth="4xl">
|
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
|
||||||
<Navigation
|
<Navigation
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
showDashboardLink={true}
|
showDashboardLink={true}
|
||||||
@@ -110,11 +110,13 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if filteredItems.length > 0}
|
{#if filteredItems.length > 0}
|
||||||
{#each filteredItems as item}
|
{#each filteredItems as item}
|
||||||
<WishlistItem {item}>
|
<WishlistItem {item} theme={data.wishlist.theme}>
|
||||||
<ReservationButton
|
<ReservationButton
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
isReserved={item.isReserved}
|
isReserved={item.isReserved}
|
||||||
reserverName={item.reservations?.[0]?.reserverName}
|
reserverName={item.reservations?.[0]?.reserverName}
|
||||||
|
reservationUserId={item.reservations?.[0]?.userId}
|
||||||
|
currentUserId={data.currentUserId}
|
||||||
/>
|
/>
|
||||||
</WishlistItem>
|
</WishlistItem>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const actions: Actions = {
|
|||||||
const title = formData.get('title');
|
const title = formData.get('title');
|
||||||
const description = formData.get('description');
|
const description = formData.get('description');
|
||||||
const endDate = formData.get('endDate');
|
const endDate = formData.get('endDate');
|
||||||
|
const theme = formData.get('theme');
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
@@ -237,6 +238,10 @@ export const actions: Actions = {
|
|||||||
updates.endDate = endDateStr ? new Date(endDateStr) : null;
|
updates.endDate = endDateStr ? new Date(endDateStr) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (theme !== null) {
|
||||||
|
updates.theme = theme?.toString().trim() || 'none';
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(wishlists)
|
await db.update(wishlists)
|
||||||
.set(updates)
|
.set(updates)
|
||||||
.where(eq(wishlists.id, wishlist.id));
|
.where(eq(wishlists.id, wishlist.id));
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
let addFormElement = $state<HTMLElement | null>(null);
|
let addFormElement = $state<HTMLElement | null>(null);
|
||||||
let editFormElement = $state<HTMLElement | null>(null);
|
let editFormElement = $state<HTMLElement | null>(null);
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
|
let currentTheme = $state(data.wishlist.theme || 'none');
|
||||||
|
|
||||||
let items = $state<Item[]>([]);
|
let items = $state<Item[]>([]);
|
||||||
|
|
||||||
@@ -105,9 +106,14 @@
|
|||||||
items = newItems;
|
items = newItems;
|
||||||
await handleReorder(newItems);
|
await handleReorder(newItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleThemeUpdate(theme: string | null) {
|
||||||
|
currentTheme = theme || 'none';
|
||||||
|
await wishlistUpdates.updateTheme(theme);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageContainer maxWidth="4xl">
|
<PageContainer maxWidth="4xl" theme={currentTheme} themeColor={data.wishlist.color}>
|
||||||
<Navigation
|
<Navigation
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
showDashboardLink={true}
|
showDashboardLink={true}
|
||||||
@@ -119,6 +125,7 @@
|
|||||||
onDescriptionUpdate={wishlistUpdates.updateDescription}
|
onDescriptionUpdate={wishlistUpdates.updateDescription}
|
||||||
onColorUpdate={wishlistUpdates.updateColor}
|
onColorUpdate={wishlistUpdates.updateColor}
|
||||||
onEndDateUpdate={wishlistUpdates.updateEndDate}
|
onEndDateUpdate={wishlistUpdates.updateEndDate}
|
||||||
|
onThemeUpdate={handleThemeUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareLinks
|
<ShareLinks
|
||||||
@@ -168,6 +175,7 @@
|
|||||||
{rearranging}
|
{rearranging}
|
||||||
onStartEditing={startEditing}
|
onStartEditing={startEditing}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
|
theme={currentTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DangerZone bind:unlocked={rearranging} />
|
<DangerZone bind:unlocked={rearranging} />
|
||||||
|
|||||||
1
static/themes/dots/bgbottom.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/dots/bgtop.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/dots/item.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/geometric/bgbottom.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/geometric/bgtop.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/geometric/item.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/waves/bgbottom.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/waves/bgtop.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
static/themes/waves/item.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><path fill="#000000" d="M0 17.83V0h17.83a3 3 0 0 1-5.66 2H5.9A5 5 0 0 1 2 5.9v6.27a3 3 0 0 1-2 5.66zm0 18.34a3 3 0 0 1 2 5.66v6.27A5 5 0 0 1 5.9 52h6.27a3 3 0 0 1 5.66 0H0V36.17zM36.17 52a3 3 0 0 1 5.66 0h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 0 1 0-5.66V52H36.17zM0 31.93v-9.78a5 5 0 0 1 3.8.72l4.43-4.43a3 3 0 1 1 1.42 1.41L5.2 24.28a5 5 0 0 1 0 5.52l4.44 4.43a3 3 0 1 1-1.42 1.42L3.8 31.2a5 5 0 0 1-3.8.72zm52-14.1a3 3 0 0 1 0-5.66V5.9A5 5 0 0 1 48.1 2h-6.27a3 3 0 0 1-5.66-2H52v17.83zm0 14.1a4.97 4.97 0 0 1-1.72-.72l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1 0-5.52l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43c.53-.35 1.12-.6 1.72-.72v9.78zM22.15 0h9.78a5 5 0 0 1-.72 3.8l4.44 4.43a3 3 0 1 1-1.42 1.42L29.8 5.2a5 5 0 0 1-5.52 0l-4.43 4.44a3 3 0 1 1-1.41-1.42l4.43-4.43a5 5 0 0 1-.72-3.8zm0 52c.13-.6.37-1.19.72-1.72l-4.43-4.43a3 3 0 1 1 1.41-1.41l4.43 4.43a5 5 0 0 1 5.52 0l4.43-4.43a3 3 0 1 1 1.42 1.41l-4.44 4.43c.36.53.6 1.12.72 1.72h-9.78zm9.75-24a5 5 0 0 1-3.9 3.9v6.27a3 3 0 1 1-2 0V31.9a5 5 0 0 1-3.9-3.9h-6.27a3 3 0 1 1 0-2h6.27a5 5 0 0 1 3.9-3.9v-6.27a3 3 0 1 1 2 0v6.27a5 5 0 0 1 3.9 3.9h6.27a3 3 0 1 1 0 2H31.9z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |