add: themes for wishlists and dashboards
This commit is contained in:
58
drizzle/relations.ts
Normal file
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
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"}),
|
||||||
|
]);
|
||||||
@@ -43,15 +43,25 @@
|
|||||||
items: [] // We don't have item data in localStorage
|
items: [] // We don't have item data in localStorage
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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>
|
</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 +99,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</WishlistSection>
|
</WishlistSection>
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -10,32 +10,35 @@
|
|||||||
let {
|
let {
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
dashboardTheme = 'none'
|
dashboardTheme = 'none',
|
||||||
|
isAuthenticated = false,
|
||||||
|
onThemeUpdate
|
||||||
}: {
|
}: {
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
dashboardTheme?: string;
|
dashboardTheme?: string;
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
onThemeUpdate?: (theme: string | null) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
const isAuthenticated = $derived(!!userName || !!userEmail);
|
|
||||||
|
|
||||||
let currentTheme = $state(dashboardTheme);
|
|
||||||
|
|
||||||
async function handleThemeChange(theme: string) {
|
async function handleThemeChange(theme: string) {
|
||||||
currentTheme = theme;
|
// Update theme immediately for instant visual feedback
|
||||||
|
if (onThemeUpdate) {
|
||||||
|
onThemeUpdate(theme);
|
||||||
|
}
|
||||||
|
|
||||||
// Submit form to update theme
|
// Only submit to database for authenticated users
|
||||||
const formData = new FormData();
|
if (isAuthenticated) {
|
||||||
formData.append('theme', theme);
|
const formData = new FormData();
|
||||||
|
formData.append('theme', theme);
|
||||||
|
|
||||||
await fetch('?/updateDashboardTheme', {
|
await fetch('?/updateDashboardTheme', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Reload to apply new theme
|
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -49,9 +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">
|
||||||
{#if isAuthenticated}
|
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} />
|
||||||
<ThemePicker value={currentTheme} onValueChange={handleThemeChange} />
|
|
||||||
{/if}
|
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{#if isAuthenticated}
|
{#if isAuthenticated}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,11 +58,13 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageContainer theme={data.user?.dashboardTheme} themeColor="hsl(var(--primary))">
|
<PageContainer theme={currentTheme} themeColor="#3b82f6">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
userName={data.user?.name}
|
userName={data.user?.name}
|
||||||
userEmail={data.user?.email}
|
userEmail={data.user?.email}
|
||||||
dashboardTheme={data.user?.dashboardTheme || 'none'}
|
dashboardTheme={currentTheme}
|
||||||
|
isAuthenticated={data.isAuthenticated}
|
||||||
|
onThemeUpdate={handleThemeUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
|
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
|
||||||
|
|||||||
@@ -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" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
|
<PageContainer maxWidth="4xl" theme={currentTheme} themeColor={data.wishlist.color}>
|
||||||
<Navigation
|
<Navigation
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
showDashboardLink={true}
|
showDashboardLink={true}
|
||||||
@@ -119,7 +125,7 @@
|
|||||||
onDescriptionUpdate={wishlistUpdates.updateDescription}
|
onDescriptionUpdate={wishlistUpdates.updateDescription}
|
||||||
onColorUpdate={wishlistUpdates.updateColor}
|
onColorUpdate={wishlistUpdates.updateColor}
|
||||||
onEndDateUpdate={wishlistUpdates.updateEndDate}
|
onEndDateUpdate={wishlistUpdates.updateEndDate}
|
||||||
onThemeUpdate={wishlistUpdates.updateTheme}
|
onThemeUpdate={handleThemeUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareLinks
|
<ShareLinks
|
||||||
@@ -169,7 +175,7 @@
|
|||||||
{rearranging}
|
{rearranging}
|
||||||
onStartEditing={startEditing}
|
onStartEditing={startEditing}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
theme={data.wishlist.theme}
|
theme={currentTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DangerZone bind:unlocked={rearranging} />
|
<DangerZone bind:unlocked={rearranging} />
|
||||||
|
|||||||
Reference in New Issue
Block a user