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);
|
||||
|
||||
let localWishlists = $state<LocalWishlist[]>([]);
|
||||
let enrichedWishlists = $state<any[]>([]);
|
||||
|
||||
// Load local wishlists on mount (client-side only)
|
||||
onMount(() => {
|
||||
// Load local wishlists on mount and fetch their data from server
|
||||
onMount(async () => {
|
||||
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);
|
||||
localWishlists = getLocalWishlists();
|
||||
await refreshEnrichedWishlists();
|
||||
}
|
||||
|
||||
function handleToggleFavorite(ownerToken: string) {
|
||||
async function handleToggleFavorite(ownerToken: string) {
|
||||
toggleLocalFavorite(ownerToken);
|
||||
localWishlists = getLocalWishlists();
|
||||
await refreshEnrichedWishlists();
|
||||
}
|
||||
|
||||
// Transform LocalWishlist to match the format expected by WishlistSection
|
||||
const transformedWishlists = $derived(() => {
|
||||
return localWishlists.map(w => ({
|
||||
id: w.ownerToken,
|
||||
title: w.title,
|
||||
ownerToken: w.ownerToken,
|
||||
publicToken: w.publicToken,
|
||||
createdAt: w.createdAt,
|
||||
isFavorite: w.isFavorite || false,
|
||||
items: [] // We don't have item data in localStorage
|
||||
}));
|
||||
// Use enriched wishlists which have full data including theme and color
|
||||
const transformedWishlists = $derived(() => enrichedWishlists);
|
||||
|
||||
// Description depends on authentication status
|
||||
const sectionDescription = $derived(() => {
|
||||
if (isAuthenticated) {
|
||||
return t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet.";
|
||||
}
|
||||
return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently.";
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if localWishlists.length > 0}
|
||||
<WishlistSection
|
||||
title={t.dashboard.localWishlists || "Local Wishlists"}
|
||||
description={t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently."}
|
||||
items={transformedWishlists()}
|
||||
emptyMessage=""
|
||||
>
|
||||
<WishlistSection
|
||||
title={t.dashboard.localWishlists || "Local Wishlists"}
|
||||
description={sectionDescription()}
|
||||
items={transformedWishlists()}
|
||||
emptyMessage={t.dashboard.emptyLocalWishlists || "No local wishlists yet"}
|
||||
emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
|
||||
emptyActionHref="/"
|
||||
showCreateButton={true}
|
||||
>
|
||||
{#snippet actions(wishlist, unlocked)}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
@@ -89,5 +152,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</WishlistSection>
|
||||
{/if}
|
||||
</WishlistSection>
|
||||
|
||||
@@ -3,26 +3,30 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
itemCount,
|
||||
color = null,
|
||||
theme = null,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
itemCount: number;
|
||||
color?: string | null;
|
||||
theme?: string | null;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const cardStyle = $derived(getCardStyle(color));
|
||||
</script>
|
||||
|
||||
<Card style={cardStyle} class="h-full flex flex-col">
|
||||
<CardHeader class="flex-shrink-0">
|
||||
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
|
||||
<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">
|
||||
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="truncate">{title}</span>
|
||||
@@ -35,7 +39,7 @@
|
||||
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
|
||||
{/if}
|
||||
</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}
|
||||
<div>
|
||||
{@render children()}
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
description={getWishlistDescription(item)}
|
||||
itemCount={wishlist.items?.length || 0}
|
||||
color={wishlist.color}
|
||||
theme={wishlist.theme}
|
||||
>
|
||||
{@render actions(item, unlocked)}
|
||||
</WishlistCard>
|
||||
|
||||
@@ -2,13 +2,44 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ThemeToggle } from '$lib/components/ui/theme-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 { 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 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>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -21,6 +52,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} />
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
{#if isAuthenticated}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<div class="min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-{maxWidth} mx-auto space-y-6">
|
||||
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden">
|
||||
<ThemeBackground themeName={theme} color={themeColor} />
|
||||
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
|
||||
{@render children()}
|
||||
</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([]),
|
||||
rearranging,
|
||||
onStartEditing,
|
||||
onReorder
|
||||
onReorder,
|
||||
theme = null
|
||||
}: {
|
||||
items: Item[];
|
||||
rearranging: boolean;
|
||||
onStartEditing: (item: Item) => void;
|
||||
onReorder: (items: Item[]) => Promise<void>;
|
||||
theme?: string | null;
|
||||
} = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
@@ -28,7 +30,7 @@
|
||||
<div class="space-y-4">
|
||||
{#each items as item (item.id)}
|
||||
<div animate:flip={{ duration: 300 }}>
|
||||
<WishlistItem {item} showDragHandle={false}>
|
||||
<WishlistItem {item} {theme} showDragHandle={false}>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -7,28 +7,81 @@
|
||||
itemId: string;
|
||||
isReserved: boolean;
|
||||
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 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>
|
||||
|
||||
{#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">
|
||||
✓ Reserved
|
||||
{#if reserverName}
|
||||
by {reserverName}
|
||||
{/if}
|
||||
</div>
|
||||
<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 canCancel()}
|
||||
{#if showCancelConfirmation}
|
||||
<div class="flex flex-col gap-2 items-start">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Cancel this reservation?
|
||||
</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>
|
||||
{:else if showReserveForm}
|
||||
<form
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { Pencil, Check, X } from "lucide-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 { languageStore } from '$lib/stores/language.svelte';
|
||||
|
||||
@@ -13,13 +14,15 @@
|
||||
onTitleUpdate,
|
||||
onDescriptionUpdate,
|
||||
onColorUpdate,
|
||||
onEndDateUpdate
|
||||
onEndDateUpdate,
|
||||
onThemeUpdate
|
||||
}: {
|
||||
wishlist: Wishlist;
|
||||
onTitleUpdate: (title: string) => Promise<boolean>;
|
||||
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
|
||||
onColorUpdate: (color: string | null) => void;
|
||||
onEndDateUpdate: (endDate: string | null) => void;
|
||||
onThemeUpdate: (theme: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
@@ -29,6 +32,7 @@
|
||||
let wishlistTitle = $state(wishlist.title);
|
||||
let wishlistDescription = $state(wishlist.description || "");
|
||||
let wishlistColor = $state<string | null>(wishlist.color);
|
||||
let wishlistTheme = $state<string>(wishlist.theme || 'none');
|
||||
let wishlistEndDate = $state<string | null>(
|
||||
wishlist.endDate
|
||||
? new Date(wishlist.endDate).toISOString().split("T")[0]
|
||||
@@ -102,7 +106,7 @@
|
||||
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"}
|
||||
>
|
||||
{#if editingTitle}
|
||||
@@ -112,7 +116,16 @@
|
||||
{/if}
|
||||
</button>
|
||||
</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
|
||||
bind:color={wishlistColor}
|
||||
onchange={() => onColorUpdate(wishlistColor)}
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||
|
||||
interface Props {
|
||||
item: Item;
|
||||
showImage?: boolean;
|
||||
children?: any;
|
||||
showDragHandle?: boolean;
|
||||
theme?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -18,6 +20,7 @@
|
||||
showImage = true,
|
||||
children,
|
||||
showDragHandle = false,
|
||||
theme = null
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
@@ -51,8 +54,9 @@
|
||||
const cardStyle = $derived(getCardStyle(item.color));
|
||||
</script>
|
||||
|
||||
<Card style={cardStyle}>
|
||||
<CardContent class="p-6">
|
||||
<Card style={cardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={theme} color={item.color} />
|
||||
<CardContent class="p-6 relative z-10">
|
||||
<div class="flex gap-4">
|
||||
{#if showDragHandle}
|
||||
<div
|
||||
|
||||
@@ -12,7 +12,8 @@ export const users = pgTable('user', {
|
||||
emailVerified: timestamp('emailVerified', { mode: 'date' }),
|
||||
image: text('image'),
|
||||
password: text('password'),
|
||||
username: text('username').unique()
|
||||
username: text('username').unique(),
|
||||
dashboardTheme: text('dashboard_theme').default('none')
|
||||
});
|
||||
|
||||
export const accounts = pgTable(
|
||||
@@ -70,6 +71,7 @@ export const wishlists = pgTable('wishlists', {
|
||||
publicToken: text('public_token').notNull().unique(),
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
color: text('color'),
|
||||
theme: text('theme').default('none'),
|
||||
endDate: timestamp('end_date', { mode: 'date' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||
@@ -115,6 +117,7 @@ export const reservations = pgTable('reservations', {
|
||||
itemId: text('item_id')
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
reserverName: text('reserver_name'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull()
|
||||
});
|
||||
@@ -123,6 +126,10 @@ export const reservationsRelations = relations(reservations, ({ one }) => ({
|
||||
item: one(items, {
|
||||
fields: [reservations.itemId],
|
||||
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 }) => ({
|
||||
wishlists: many(wishlists),
|
||||
savedWishlists: many(savedWishlists)
|
||||
savedWishlists: many(savedWishlists),
|
||||
reservations: many(reservations)
|
||||
}));
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
class ThemeStore {
|
||||
current = $state<Theme>('system');
|
||||
@@ -11,10 +12,8 @@ class ThemeStore {
|
||||
this.current = stored || 'system';
|
||||
this.applyTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
// Re-apply theme if in system mode
|
||||
if (this.current === 'system') {
|
||||
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() {
|
||||
// Cycle through: light -> dark -> system -> 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 || "" });
|
||||
}
|
||||
|
||||
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> {
|
||||
const response = await fetch("?/reorderItems", {
|
||||
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 type { PageServerLoad, Actions } from './$types';
|
||||
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';
|
||||
|
||||
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({
|
||||
where: eq(wishlists.userId, session.user.id),
|
||||
with: {
|
||||
@@ -57,7 +62,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
}));
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
user: user,
|
||||
wishlists: userWishlists,
|
||||
savedWishlists: savedWithAccess,
|
||||
isAuthenticated: true
|
||||
@@ -149,6 +154,26 @@ export const actions: Actions = {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,30 @@
|
||||
|
||||
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);
|
||||
|
||||
// Only owned wishlists for "My Wishlists"
|
||||
@@ -34,8 +58,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageContainer>
|
||||
<DashboardHeader userName={data.user?.name} userEmail={data.user?.email} />
|
||||
<PageContainer theme={currentTheme} themeColor={null}>
|
||||
<DashboardHeader
|
||||
userName={data.user?.name}
|
||||
userEmail={data.user?.email}
|
||||
dashboardTheme={currentTheme}
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
onThemeUpdate={handleThemeUpdate}
|
||||
/>
|
||||
|
||||
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
|
||||
<LocalWishlistsSection isAuthenticated={data.isAuthenticated} />
|
||||
|
||||
@@ -43,12 +43,13 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
isSaved,
|
||||
isClaimed,
|
||||
savedWishlistId,
|
||||
isAuthenticated: !!session?.user
|
||||
isAuthenticated: !!session?.user,
|
||||
currentUserId: session?.user?.id || null
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
reserve: async ({ request }) => {
|
||||
reserve: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get('itemId') 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' };
|
||||
}
|
||||
|
||||
const session = await locals.auth();
|
||||
|
||||
const existingReservation = await db.query.reservations.findFirst({
|
||||
where: eq(reservations.itemId, itemId)
|
||||
});
|
||||
@@ -68,6 +71,7 @@ export const actions: Actions = {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(reservations).values({
|
||||
itemId,
|
||||
userId: session?.user?.id || null,
|
||||
reserverName: reserverName?.trim() || null
|
||||
});
|
||||
|
||||
@@ -80,7 +84,7 @@ export const actions: Actions = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
unreserve: async ({ request }) => {
|
||||
unreserve: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get('itemId') as string;
|
||||
|
||||
@@ -88,6 +92,25 @@ export const actions: Actions = {
|
||||
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 tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageContainer maxWidth="4xl">
|
||||
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
|
||||
<Navigation
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
showDashboardLink={true}
|
||||
@@ -110,11 +110,13 @@
|
||||
<div class="space-y-4">
|
||||
{#if filteredItems.length > 0}
|
||||
{#each filteredItems as item}
|
||||
<WishlistItem {item}>
|
||||
<WishlistItem {item} theme={data.wishlist.theme}>
|
||||
<ReservationButton
|
||||
itemId={item.id}
|
||||
isReserved={item.isReserved}
|
||||
reserverName={item.reservations?.[0]?.reserverName}
|
||||
reservationUserId={item.reservations?.[0]?.userId}
|
||||
currentUserId={data.currentUserId}
|
||||
/>
|
||||
</WishlistItem>
|
||||
{/each}
|
||||
|
||||
@@ -203,6 +203,7 @@ export const actions: Actions = {
|
||||
const title = formData.get('title');
|
||||
const description = formData.get('description');
|
||||
const endDate = formData.get('endDate');
|
||||
const theme = formData.get('theme');
|
||||
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token)
|
||||
@@ -237,6 +238,10 @@ export const actions: Actions = {
|
||||
updates.endDate = endDateStr ? new Date(endDateStr) : null;
|
||||
}
|
||||
|
||||
if (theme !== null) {
|
||||
updates.theme = theme?.toString().trim() || 'none';
|
||||
}
|
||||
|
||||
await db.update(wishlists)
|
||||
.set(updates)
|
||||
.where(eq(wishlists.id, wishlist.id));
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
let addFormElement = $state<HTMLElement | null>(null);
|
||||
let editFormElement = $state<HTMLElement | null>(null);
|
||||
let searchQuery = $state("");
|
||||
let currentTheme = $state(data.wishlist.theme || 'none');
|
||||
|
||||
let items = $state<Item[]>([]);
|
||||
|
||||
@@ -105,9 +106,14 @@
|
||||
items = newItems;
|
||||
await handleReorder(newItems);
|
||||
}
|
||||
|
||||
async function handleThemeUpdate(theme: string | null) {
|
||||
currentTheme = theme || 'none';
|
||||
await wishlistUpdates.updateTheme(theme);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageContainer maxWidth="4xl">
|
||||
<PageContainer maxWidth="4xl" theme={currentTheme} themeColor={data.wishlist.color}>
|
||||
<Navigation
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
showDashboardLink={true}
|
||||
@@ -119,6 +125,7 @@
|
||||
onDescriptionUpdate={wishlistUpdates.updateDescription}
|
||||
onColorUpdate={wishlistUpdates.updateColor}
|
||||
onEndDateUpdate={wishlistUpdates.updateEndDate}
|
||||
onThemeUpdate={handleThemeUpdate}
|
||||
/>
|
||||
|
||||
<ShareLinks
|
||||
@@ -168,6 +175,7 @@
|
||||
{rearranging}
|
||||
onStartEditing={startEditing}
|
||||
onReorder={handleReorder}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
|
||||
<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 |