Merge pull request 'feature/builtin-themes' (#4) from feature/builtin-themes into master

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2025-12-14 19:49:13 +00:00
37 changed files with 852 additions and 61 deletions

58
drizzle/relations.ts Normal file
View 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
View 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"}),
]);

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View 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}

View 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}

View 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}

View 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}

View 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}

View 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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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)}

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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');
}

View File

@@ -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",

View 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 || []
});
};

View File

@@ -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 };
} }
}; };

View File

@@ -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} />

View File

@@ -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));

View File

@@ -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}

View File

@@ -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));

View File

@@ -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} />

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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