Compare commits
18 Commits
a3c4067a4c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b1e2b8dd3 | |||
|
|
7453c356bb | ||
|
|
a5e47cdc1f | ||
|
|
b3f123388f | ||
|
|
8f574380ce | ||
|
|
cb4539a982 | ||
|
|
466704a23a | ||
|
|
b381a6d669 | ||
| 2b12896374 | |||
|
|
bdfcdcc15b | ||
|
|
b848477729 | ||
|
|
ed9da14fa5 | ||
| 23ff65f3e7 | |||
|
|
ac81b8175c | ||
|
|
19493b4cd3 | ||
|
|
b80ef2cfea | ||
| 22f9f8f0c9 | |||
| 3af098505b |
3
.gitignore
vendored
@@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
|
||||
# Infisical
|
||||
.infisical*
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Coolify Deployment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Coolify instance
|
||||
- Git repository
|
||||
|
||||
## Database Setup
|
||||
|
||||
Choose one:
|
||||
|
||||
**Option A: Docker Compose (Recommended)**
|
||||
- Database included in `docker-compose.coolify.yml`
|
||||
- Skip to step 2
|
||||
|
||||
**Option B: Separate PostgreSQL Resource**
|
||||
1. Create PostgreSQL database in Coolify
|
||||
2. Note connection details
|
||||
|
||||
## Deploy
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
1. Create application in Coolify
|
||||
2. Select Git repository
|
||||
3. Configure:
|
||||
- Build Pack: Docker Compose
|
||||
- File: `./docker-compose.coolify.yml`
|
||||
4. Assign domain to `app` service only (format: `http://yourdomain.com:3000`)
|
||||
5. Set environment variables:
|
||||
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
|
||||
- `AUTH_URL` (your domain with https)
|
||||
- `POSTGRES_DATA_PATH` (optional, for custom database storage location)
|
||||
- `GOOGLE_CLIENT_ID` (optional)
|
||||
- `GOOGLE_CLIENT_SECRET` (optional)
|
||||
6. Deploy
|
||||
|
||||
### Using Dockerfile
|
||||
|
||||
1. Create application in Coolify
|
||||
2. Select Git repository
|
||||
3. Configure:
|
||||
- Build Pack: Docker
|
||||
- Port: `3000`
|
||||
4. Add domain
|
||||
5. Set environment variables:
|
||||
- `DATABASE_URL`
|
||||
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
|
||||
- `AUTH_URL` (your domain with https)
|
||||
- `GOOGLE_CLIENT_ID` (optional)
|
||||
- `GOOGLE_CLIENT_SECRET` (optional)
|
||||
6. Deploy
|
||||
|
||||
## After Deployment
|
||||
|
||||
Run migrations:
|
||||
```bash
|
||||
# In Coolify terminal or SSH
|
||||
docker exec -it <container-name> bun run db:push
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required:
|
||||
- `DATABASE_URL` - Connection string
|
||||
- `AUTH_SECRET` - Random secret
|
||||
- `AUTH_URL` - Your app URL
|
||||
- `AUTH_TRUST_HOST` - `true`
|
||||
|
||||
Optional:
|
||||
- `GOOGLE_CLIENT_ID`
|
||||
- `GOOGLE_CLIENT_SECRET`
|
||||
- `POSTGRES_DATA_PATH` - Custom path for PostgreSQL data (Docker Compose only)
|
||||
- Example: `/mnt/storage/wishlist/postgres`
|
||||
- If not set, uses a Docker named volume
|
||||
- Path must exist with proper permissions before deployment
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container crashes:** Check logs in Coolify dashboard
|
||||
|
||||
**Database connection:** Verify `DATABASE_URL` format
|
||||
|
||||
**Auth issues:** Check `AUTH_URL` matches your domain
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL.
|
||||
|
||||

|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Coolify-optimized Docker Compose
|
||||
# Includes both app and database - database is only exposed internally
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-wishlistuser}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wishlistpassword}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-wishlist}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${POSTGRES_DATA_PATH}
|
||||
target: /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wishlistuser} -d ${POSTGRES_DB:-wishlist}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# NOTE: No ports exposed - only accessible internally by app service
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
# Coolify will inject these from Environment Variables
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-wishlistuser}:${POSTGRES_PASSWORD:-wishlistpassword}@db:5432/${POSTGRES_DB:-wishlist}
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
AUTH_URL: ${AUTH_URL:-https://wish.rasmusq.com}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
|
||||
- traefik.http.routers.wishlist.entryPoints=https
|
||||
- traefik.http.routers.wishlist.tls=true
|
||||
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.wishlist.loadbalancer.server.port=3000
|
||||
# Forward headers for Auth.js behind reverse proxy
|
||||
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Proto=https
|
||||
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Host=wish.rasmusq.com
|
||||
- traefik.http.routers.wishlist.middlewares=wishlist-headers
|
||||
21
docker-compose.dev.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
container_name: wishlist-postgres-test
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
61
docker-compose.prod.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
container_name: wishlist-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- wishlist-net
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: wishlist-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
AUTH_URL: ${AUTH_URL}
|
||||
AUTH_TRUST_HOST: "true"
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- wishlist-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik-net
|
||||
# HTTPS router
|
||||
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
|
||||
- traefik.http.routers.wishlist.entrypoints=websecure
|
||||
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
|
||||
# Forward headers for Auth.js
|
||||
- traefik.http.routers.wishlist.middlewares=wishlist-headers
|
||||
- traefik.http.middlewares.wishlist-headers.headers.customRequestHeaders.X-Forwarded-Proto=https
|
||||
- traefik.http.middlewares.wishlist-headers.headers.customRequestHeaders.X-Forwarded-Host=wish.rasmusq.com
|
||||
- traefik.http.services.wishlist.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
wishlist-net:
|
||||
name: wishlist-net
|
||||
traefik-net:
|
||||
external: true
|
||||
@@ -1,41 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wishlist-db
|
||||
environment:
|
||||
POSTGRES_USER: wishlistuser
|
||||
POSTGRES_PASSWORD: wishlistpassword
|
||||
POSTGRES_DB: wishlist
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wishlistuser -d wishlist"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: wishlist-app
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wishlistuser:wishlistpassword@db:5432/wishlist
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
|
||||
AUTH_URL: ${AUTH_URL:-http://localhost:3000}
|
||||
AUTH_TRUST_HOST: true
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -77,6 +77,10 @@ export const user = pgTable("user", {
|
||||
password: text(),
|
||||
username: text(),
|
||||
dashboardTheme: text("dashboard_theme").default('none'),
|
||||
dashboardColor: text("dashboard_color"),
|
||||
lastLogin: timestamp("last_login", { mode: 'string' }),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
unique("user_email_unique").on(table.email),
|
||||
unique("user_username_unique").on(table.username),
|
||||
|
||||
BIN
readme-assets/create.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
readme-assets/dashboard.png
Normal file
|
After Width: | Height: | Size: 708 KiB |
@@ -13,6 +13,7 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
@@ -48,6 +49,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
|
||||
@@ -103,6 +103,14 @@ const authConfig: SvelteKitAuthConfig = {
|
||||
signIn: '/signin'
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user }) {
|
||||
if (user?.id) {
|
||||
await db.update(users)
|
||||
.set({ lastLogin: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
isAuthenticated = false
|
||||
isAuthenticated = false,
|
||||
fallbackColor = null,
|
||||
fallbackTheme = null
|
||||
}: {
|
||||
isAuthenticated?: boolean;
|
||||
fallbackColor?: string | null;
|
||||
fallbackTheme?: string | null;
|
||||
} = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
@@ -114,6 +118,8 @@
|
||||
emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
|
||||
emptyActionHref="/"
|
||||
showCreateButton={true}
|
||||
fallbackColor={fallbackColor}
|
||||
fallbackTheme={fallbackTheme}
|
||||
>
|
||||
{#snippet actions(wishlist, unlocked)}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
itemCount,
|
||||
color = null,
|
||||
theme = null,
|
||||
fallbackColor = null,
|
||||
fallbackTheme = null,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
@@ -18,14 +20,18 @@
|
||||
itemCount: number;
|
||||
color?: string | null;
|
||||
theme?: string | null;
|
||||
fallbackColor?: string | null;
|
||||
fallbackTheme?: string | null;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const cardStyle = $derived(getCardStyle(color));
|
||||
const finalColor = $derived(color || fallbackColor);
|
||||
const finalTheme = $derived(theme || fallbackTheme);
|
||||
const cardStyle = $derived(getCardStyle(color, fallbackColor));
|
||||
</script>
|
||||
|
||||
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
|
||||
<ThemeCard themeName={theme} color={color} />
|
||||
<ThemeCard themeName={finalTheme} color={finalColor} />
|
||||
<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">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import EmptyState from '$lib/components/layout/EmptyState.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
@@ -13,6 +15,8 @@
|
||||
emptyDescription,
|
||||
emptyActionLabel,
|
||||
emptyActionHref,
|
||||
fallbackColor = null,
|
||||
fallbackTheme = null,
|
||||
headerAction,
|
||||
searchBar,
|
||||
children
|
||||
@@ -24,11 +28,15 @@
|
||||
emptyDescription?: string;
|
||||
emptyActionLabel?: string;
|
||||
emptyActionHref?: string;
|
||||
fallbackColor?: string | null;
|
||||
fallbackTheme?: string | null;
|
||||
headerAction?: Snippet;
|
||||
searchBar?: Snippet;
|
||||
children: Snippet<[any]>;
|
||||
} = $props();
|
||||
|
||||
const cardStyle = $derived(getCardStyle(fallbackColor, null));
|
||||
|
||||
let scrollContainer: HTMLElement | null = null;
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
@@ -44,8 +52,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card style={cardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={fallbackTheme} color={fallbackColor} showPattern={false} />
|
||||
<CardHeader class="relative z-10">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
@@ -63,7 +72,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="relative z-10">
|
||||
{#if items && items.length > 0}
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
emptyActionHref,
|
||||
showCreateButton = false,
|
||||
hideIfEmpty = false,
|
||||
fallbackColor = null,
|
||||
fallbackTheme = null,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
@@ -32,6 +34,8 @@
|
||||
emptyActionHref?: string;
|
||||
showCreateButton?: boolean;
|
||||
hideIfEmpty?: boolean;
|
||||
fallbackColor?: string | null;
|
||||
fallbackTheme?: string | null;
|
||||
actions: Snippet<[WishlistItem, boolean]>; // item, unlocked
|
||||
} = $props();
|
||||
|
||||
@@ -126,6 +130,8 @@
|
||||
{emptyDescription}
|
||||
{emptyActionLabel}
|
||||
{emptyActionHref}
|
||||
{fallbackColor}
|
||||
{fallbackTheme}
|
||||
>
|
||||
{#snippet headerAction()}
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
@@ -150,6 +156,8 @@
|
||||
itemCount={wishlist.items?.length || 0}
|
||||
color={wishlist.color}
|
||||
theme={wishlist.theme}
|
||||
fallbackColor={fallbackColor}
|
||||
fallbackTheme={fallbackTheme}
|
||||
>
|
||||
{@render actions(item, unlocked)}
|
||||
</WishlistCard>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||
import { signOut } from '@auth/sveltekit/client';
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
@@ -11,25 +12,27 @@
|
||||
userName,
|
||||
userEmail,
|
||||
dashboardTheme = 'none',
|
||||
dashboardColor = null,
|
||||
isAuthenticated = false,
|
||||
onThemeUpdate
|
||||
onThemeUpdate,
|
||||
onColorUpdate
|
||||
}: {
|
||||
userName?: string | null;
|
||||
userEmail?: string | null;
|
||||
dashboardTheme?: string;
|
||||
dashboardColor?: string | null;
|
||||
isAuthenticated?: boolean;
|
||||
onThemeUpdate?: (theme: string | null) => void;
|
||||
onColorUpdate?: (color: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
|
||||
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);
|
||||
@@ -40,6 +43,30 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let localColor = $state(dashboardColor);
|
||||
|
||||
$effect(() => {
|
||||
localColor = dashboardColor;
|
||||
});
|
||||
|
||||
async function handleColorChange() {
|
||||
if (onColorUpdate) {
|
||||
onColorUpdate(localColor);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
const formData = new FormData();
|
||||
if (localColor) {
|
||||
formData.append('color', localColor);
|
||||
}
|
||||
|
||||
await fetch('?/updateDashboardColor', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -52,8 +79,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} />
|
||||
<LanguageToggle />
|
||||
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
|
||||
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
|
||||
<LanguageToggle color={localColor} />
|
||||
<ThemeToggle />
|
||||
{#if isAuthenticated}
|
||||
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
let {
|
||||
isAuthenticated = false,
|
||||
showDashboardLink = false
|
||||
showDashboardLink = false,
|
||||
color = null
|
||||
}: {
|
||||
isAuthenticated?: boolean;
|
||||
showDashboardLink?: boolean;
|
||||
color?: string | null;
|
||||
} = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
@@ -28,7 +30,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center gap-1 sm:gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<LanguageToggle {color} />
|
||||
<ThemeToggle size="sm" {color} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
|
||||
import { hexToRgba } from '$lib/utils/colors';
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
@@ -13,9 +15,20 @@
|
||||
theme?: string | null;
|
||||
themeColor?: string | null;
|
||||
} = $props();
|
||||
|
||||
const backgroundStyle = $derived.by(() => {
|
||||
if (!themeColor) return '';
|
||||
|
||||
const isDark = themeStore.getResolvedTheme() === 'dark';
|
||||
const tintedColor = hexToRgba(themeColor, 0.15);
|
||||
|
||||
return isDark
|
||||
? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;`
|
||||
: `background-color: ${tintedColor};`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden">
|
||||
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden" style={backgroundStyle}>
|
||||
<ThemeBackground themeName={theme} color={themeColor} />
|
||||
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
|
||||
{@render children()}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import TopPattern from './svgs/TopPattern.svelte';
|
||||
import BottomPattern from './svgs/BottomPattern.svelte';
|
||||
import { getTheme, getPatternColor, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
|
||||
let {
|
||||
themeName,
|
||||
@@ -16,7 +17,10 @@
|
||||
} = $props();
|
||||
|
||||
const theme = $derived(getTheme(themeName));
|
||||
const patternColor = $derived(getPatternColor(color));
|
||||
const patternColor = $derived.by(() => {
|
||||
const isDark = themeStore.getResolvedTheme() === 'dark';
|
||||
return isDark ? '#FFFFFF' : '#000000';
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if theme.pattern !== 'none'}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<script lang="ts">
|
||||
import CardPattern from './svgs/CardPattern.svelte';
|
||||
import { getTheme, getPatternColor, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
|
||||
let {
|
||||
themeName,
|
||||
color
|
||||
color,
|
||||
showPattern = true
|
||||
}: {
|
||||
themeName?: string;
|
||||
color?: string;
|
||||
themeName?: string | null;
|
||||
color?: string | null;
|
||||
showPattern?: boolean;
|
||||
} = $props();
|
||||
|
||||
const theme = $derived(getTheme(themeName));
|
||||
const patternColor = $derived(getPatternColor(color));
|
||||
const patternColor = $derived.by(() => {
|
||||
const isDark = themeStore.getResolvedTheme() === 'dark';
|
||||
return isDark ? '#FFFFFF' : '#000000';
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if theme.pattern !== 'none'}
|
||||
{#if showPattern && theme.pattern !== 'none'}
|
||||
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||
{/if}
|
||||
|
||||
@@ -16,17 +16,15 @@
|
||||
|
||||
{#if pattern !== 'none'}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 pointer-events-none overflow-hidden"
|
||||
class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0"
|
||||
style="
|
||||
mask-image: url({patternPath});
|
||||
-webkit-mask-image: url({patternPath});
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: left bottom;
|
||||
background-color: {color};
|
||||
opacity: {opacity};
|
||||
height: 200px;
|
||||
height: 100vh;
|
||||
"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -16,18 +16,15 @@
|
||||
|
||||
{#if pattern !== 'none'}
|
||||
<div
|
||||
class="absolute bottom-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
|
||||
class="absolute bottom-0 top-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;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: right bottom;
|
||||
background-color: {color};
|
||||
opacity: {opacity};
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -16,17 +16,15 @@
|
||||
|
||||
{#if pattern !== 'none'}
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 pointer-events-none overflow-hidden"
|
||||
class="fixed top-0 right-0 left-0 pointer-events-none z-0"
|
||||
style="
|
||||
mask-image: url({patternPath});
|
||||
-webkit-mask-image: url({patternPath});
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
mask-repeat: repeat;
|
||||
-webkit-mask-repeat: repeat;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: right top;
|
||||
background-color: {color};
|
||||
opacity: {opacity};
|
||||
height: 200px;
|
||||
height: 100vh;
|
||||
"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { X, Pencil } from 'lucide-svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
let {
|
||||
color = $bindable(null),
|
||||
@@ -18,7 +19,7 @@
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5'
|
||||
};
|
||||
@@ -39,17 +40,18 @@
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if color}
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onclick={clearColor}
|
||||
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||
{color}
|
||||
{size}
|
||||
aria-label="Clear color"
|
||||
rounded="md"
|
||||
>
|
||||
<X class={iconSize} />
|
||||
</button>
|
||||
</IconButton>
|
||||
{/if}
|
||||
<label
|
||||
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
|
||||
class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
|
||||
style={color ? `background-color: ${color};` : ''}
|
||||
>
|
||||
<Pencil class="{iconSize} relative z-10 pointer-events-none" style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''} />
|
||||
|
||||
133
src/lib/components/ui/Dropdown.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { scale } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { Snippet } from 'svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
color,
|
||||
showCheckmark = true,
|
||||
icon,
|
||||
ariaLabel
|
||||
}: {
|
||||
items: Array<{ value: string; label: string }>;
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
color?: string | null;
|
||||
showCheckmark?: boolean;
|
||||
icon: Snippet;
|
||||
ariaLabel: string;
|
||||
} = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
|
||||
const menuClasses = $derived(
|
||||
color
|
||||
? 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md'
|
||||
: 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-950/90'
|
||||
);
|
||||
|
||||
const menuStyle = $derived(
|
||||
color
|
||||
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
|
||||
: ''
|
||||
);
|
||||
|
||||
function getItemStyle(itemValue: string): string {
|
||||
if (!color) return '';
|
||||
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
showMenu = !showMenu;
|
||||
}
|
||||
|
||||
function handleSelect(value: string) {
|
||||
onSelect(value);
|
||||
showMenu = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.dropdown-menu')) {
|
||||
showMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(e: MouseEvent) {
|
||||
if (color) {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave(e: MouseEvent, itemValue: string) {
|
||||
if (color) {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor =
|
||||
selectedValue === itemValue ? `${color}20` : 'transparent';
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showMenu) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative dropdown-menu">
|
||||
<IconButton
|
||||
size="sm"
|
||||
rounded="md"
|
||||
onclick={toggleMenu}
|
||||
aria-label={ariaLabel}
|
||||
class={color ? 'hover-themed' : ''}
|
||||
style={color ? `--hover-bg: ${color}20;` : ''}
|
||||
>
|
||||
{@render icon()}
|
||||
</IconButton>
|
||||
|
||||
{#if showMenu}
|
||||
<div
|
||||
class={menuClasses}
|
||||
style={menuStyle}
|
||||
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
|
||||
>
|
||||
<div class="py-1">
|
||||
{#each items as item}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 text-sm transition-colors"
|
||||
class:hover:bg-slate-100={!color}
|
||||
class:dark:hover:bg-slate-900={!color}
|
||||
class:font-bold={selectedValue === item.value}
|
||||
class:bg-slate-100={selectedValue === item.value && !color}
|
||||
class:dark:bg-slate-900={selectedValue === item.value && !color}
|
||||
class:flex={showCheckmark}
|
||||
class:items-center={showCheckmark}
|
||||
class:justify-between={showCheckmark}
|
||||
style={getItemStyle(item.value)}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={(e) => handleMouseLeave(e, item.value)}
|
||||
onclick={() => handleSelect(item.value)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{#if showCheckmark && selectedValue === item.value}
|
||||
<span class="ml-2">✓</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.hover-themed:hover) {
|
||||
background-color: var(--hover-bg) !important;
|
||||
}
|
||||
</style>
|
||||
52
src/lib/components/ui/IconButton.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
color?: string | null;
|
||||
rounded?: 'full' | 'md' | 'lg';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
color = null,
|
||||
rounded = 'full',
|
||||
size = 'md',
|
||||
class: className = '',
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
full: 'rounded-full',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg'
|
||||
};
|
||||
|
||||
const baseClasses = 'flex items-center justify-center border border-input transition-colors backdrop-blur';
|
||||
const sizeClass = sizeClasses[size];
|
||||
const roundedClass = roundedClasses[rounded];
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
|
||||
class:hover:bg-accent={!color}
|
||||
style={color ? `--hover-bg: ${color}20;` : ''}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button[style*='--hover-bg']:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import { languages } from '$lib/i18n/translations';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Dropdown from '$lib/components/ui/Dropdown.svelte';
|
||||
import { Languages } from 'lucide-svelte';
|
||||
|
||||
let showMenu = $state(false);
|
||||
let { color }: { color?: string | null } = $props();
|
||||
|
||||
function toggleMenu() {
|
||||
showMenu = !showMenu;
|
||||
const languageItems = $derived(
|
||||
languages.map((lang) => ({
|
||||
value: lang.code,
|
||||
label: lang.name
|
||||
}))
|
||||
);
|
||||
|
||||
function setLanguage(code: string) {
|
||||
languageStore.setLanguage(code as 'en' | 'da');
|
||||
}
|
||||
|
||||
function setLanguage(code: 'en' | 'da') {
|
||||
languageStore.setLanguage(code);
|
||||
showMenu = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.language-toggle-menu')) {
|
||||
showMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showMenu) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative language-toggle-menu">
|
||||
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Toggle language">
|
||||
<Dropdown
|
||||
items={languageItems}
|
||||
selectedValue={languageStore.current}
|
||||
onSelect={setLanguage}
|
||||
{color}
|
||||
showCheckmark={false}
|
||||
ariaLabel="Toggle language"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Languages 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 languages as lang}
|
||||
<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"
|
||||
class:font-bold={languageStore.current === lang.code}
|
||||
class:bg-slate-100={languageStore.current === lang.code}
|
||||
class:dark:bg-slate-900={languageStore.current === lang.code}
|
||||
onclick={() => setLanguage(lang.code)}
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,68 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Dropdown from '$lib/components/ui/Dropdown.svelte';
|
||||
import { Palette } from 'lucide-svelte';
|
||||
import { AVAILABLE_THEMES } from '$lib/utils/themes';
|
||||
|
||||
let {
|
||||
value = 'none',
|
||||
onValueChange
|
||||
onValueChange,
|
||||
color
|
||||
}: {
|
||||
value?: string;
|
||||
onValueChange: (theme: string) => void;
|
||||
color?: string | null;
|
||||
} = $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);
|
||||
}
|
||||
});
|
||||
const themeItems = $derived(
|
||||
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
|
||||
value: key,
|
||||
label: theme.name
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="relative theme-picker-menu">
|
||||
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Select theme pattern">
|
||||
<Dropdown
|
||||
items={themeItems}
|
||||
selectedValue={value}
|
||||
onSelect={onValueChange}
|
||||
{color}
|
||||
showCheckmark={true}
|
||||
ariaLabel="Select theme pattern"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<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>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
|
||||
let {
|
||||
color = $bindable(null),
|
||||
size = 'sm',
|
||||
}: {
|
||||
color: string | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
} = $props();
|
||||
|
||||
function toggle() {
|
||||
themeStore.toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button onclick={toggle} variant="ghost" size="icon" class="rounded-full">
|
||||
<IconButton onclick={toggle} {size} {color} rounded="md">
|
||||
{#if themeStore.current === 'light'}
|
||||
<Sun size={20} />
|
||||
<span class="sr-only">Light mode (click for dark)</span>
|
||||
@@ -19,4 +27,4 @@
|
||||
<Monitor size={20} />
|
||||
<span class="sr-only">System mode (click for light)</span>
|
||||
{/if}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
@@ -8,12 +8,18 @@
|
||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
|
||||
interface Props {
|
||||
onSuccess?: () => void;
|
||||
wishlistColor?: string | null;
|
||||
wishlistTheme?: string | null;
|
||||
}
|
||||
|
||||
let { onSuccess }: Props = $props();
|
||||
let { onSuccess, wishlistColor = null, wishlistTheme = null }: Props = $props();
|
||||
|
||||
const cardStyle = $derived(getCardStyle(wishlistColor, null));
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
|
||||
@@ -53,11 +59,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card style={cardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
|
||||
<CardHeader class="relative z-10">
|
||||
<CardTitle>{t.form.addNewWish}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="relative z-10">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import type { Item } from '$lib/server/schema';
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
|
||||
interface Props {
|
||||
item: Item;
|
||||
@@ -18,9 +20,13 @@
|
||||
currentPosition?: number;
|
||||
totalItems?: number;
|
||||
onPositionChange?: (newPosition: number) => void;
|
||||
wishlistColor?: string | null;
|
||||
wishlistTheme?: string | null;
|
||||
}
|
||||
|
||||
let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange }: Props = $props();
|
||||
let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange, wishlistColor = null, wishlistTheme = null }: Props = $props();
|
||||
|
||||
const cardStyle = $derived(getCardStyle(wishlistColor, null));
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
|
||||
@@ -60,11 +66,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card style={cardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
|
||||
<CardHeader class="relative z-10">
|
||||
<CardTitle>{t.wishlist.editWish}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="relative z-10">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateItem"
|
||||
|
||||
@@ -7,22 +7,27 @@
|
||||
import { enhance } from "$app/forms";
|
||||
import { flip } from "svelte/animate";
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
|
||||
import { getCardStyle } from "$lib/utils/colors";
|
||||
|
||||
let {
|
||||
items = $bindable([]),
|
||||
rearranging,
|
||||
onStartEditing,
|
||||
onReorder,
|
||||
theme = null
|
||||
theme = null,
|
||||
wishlistColor = null
|
||||
}: {
|
||||
items: Item[];
|
||||
rearranging: boolean;
|
||||
onStartEditing: (item: Item) => void;
|
||||
onReorder: (items: Item[]) => Promise<void>;
|
||||
theme?: string | null;
|
||||
wishlistColor?: string | null;
|
||||
} = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
const cardStyle = $derived(getCardStyle(wishlistColor));
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -30,7 +35,7 @@
|
||||
<div class="space-y-4">
|
||||
{#each items as item (item.id)}
|
||||
<div animate:flip={{ duration: 300 }}>
|
||||
<WishlistItem {item} {theme} showDragHandle={false}>
|
||||
<WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -66,8 +71,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="p-12">
|
||||
<Card style={cardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
|
||||
<CardContent class="p-12 relative z-10">
|
||||
<EmptyState
|
||||
message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"}
|
||||
/>
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Card, CardContent } from '$lib/components/ui/card';
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
|
||||
interface Props {
|
||||
publicUrl: string;
|
||||
ownerUrl?: string;
|
||||
wishlistColor?: string | null;
|
||||
}
|
||||
|
||||
let { publicUrl, ownerUrl }: Props = $props();
|
||||
let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
const cardStyle = $derived(getCardStyle(null, wishlistColor));
|
||||
|
||||
let copiedPublic = $state(false);
|
||||
let copiedOwner = $state(false);
|
||||
@@ -34,7 +37,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<Card style={cardStyle}>
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<div class="space-y-2">
|
||||
<Label>{t.wishlist.shareViewOnly}</Label>
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
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 IconButton from "$lib/components/ui/IconButton.svelte";
|
||||
import type { Wishlist } from "$lib/server/schema";
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import { getCardStyle } from '$lib/utils/colors';
|
||||
|
||||
let {
|
||||
wishlist,
|
||||
@@ -39,6 +41,8 @@
|
||||
: null,
|
||||
);
|
||||
|
||||
const cardStyle = $derived(getCardStyle(null, wishlistColor));
|
||||
|
||||
async function saveTitle() {
|
||||
if (!wishlistTitle.trim()) {
|
||||
wishlistTitle = wishlist.title;
|
||||
@@ -77,7 +81,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Title Header -->
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
{#if editingTitle}
|
||||
@@ -97,8 +100,7 @@
|
||||
{:else}
|
||||
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onclick={() => {
|
||||
if (editingTitle) {
|
||||
saveTitle();
|
||||
@@ -106,7 +108,9 @@
|
||||
editingTitle = true;
|
||||
}
|
||||
}}
|
||||
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||
color={wishlistColor}
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
aria-label={editingTitle ? "Save title" : "Edit title"}
|
||||
>
|
||||
{#if editingTitle}
|
||||
@@ -114,7 +118,7 @@
|
||||
{:else}
|
||||
<Pencil class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<ThemePicker
|
||||
@@ -125,23 +129,22 @@
|
||||
// Force reactivity by updating the wishlist object
|
||||
wishlist.theme = theme;
|
||||
}}
|
||||
color={wishlistColor}
|
||||
/>
|
||||
<ColorPicker
|
||||
bind:color={wishlistColor}
|
||||
onchange={() => onColorUpdate(wishlistColor)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<Card>
|
||||
<Card style={cardStyle}>
|
||||
<CardContent class="pt-6 space-y-4">
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label for="wishlist-description">{t.form.descriptionOptional}</Label>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onclick={() => {
|
||||
if (editingDescription) {
|
||||
saveDescription();
|
||||
@@ -149,7 +152,9 @@
|
||||
editingDescription = true;
|
||||
}
|
||||
}}
|
||||
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||
color={wishlistColor}
|
||||
size="sm"
|
||||
class="flex-shrink-0"
|
||||
aria-label={editingDescription ? "Save description" : "Edit description"}
|
||||
>
|
||||
{#if editingDescription}
|
||||
@@ -157,7 +162,7 @@
|
||||
{:else}
|
||||
<Pencil class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
{#if editingDescription}
|
||||
<Textarea
|
||||
@@ -180,19 +185,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||
<Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if wishlistEndDate}
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onclick={clearEndDate}
|
||||
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||
color={wishlistColor}
|
||||
size="sm"
|
||||
class="flex-shrink-0"
|
||||
aria-label="Clear end date"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</IconButton>
|
||||
{/if}
|
||||
<Input
|
||||
id="wishlist-end-date"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
children?: any;
|
||||
showDragHandle?: boolean;
|
||||
theme?: string | null;
|
||||
wishlistColor?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,7 +21,8 @@
|
||||
showImage = true,
|
||||
children,
|
||||
showDragHandle = false,
|
||||
theme = null
|
||||
theme = null,
|
||||
wishlistColor = null
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
@@ -51,11 +53,11 @@
|
||||
return `${symbol}${amount}`;
|
||||
}
|
||||
|
||||
const cardStyle = $derived(getCardStyle(item.color));
|
||||
const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
|
||||
</script>
|
||||
|
||||
<Card style={cardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={theme} color={item.color} />
|
||||
<ThemeCard themeName={theme} color={item.color} showPattern={false} />
|
||||
<CardContent class="p-6 relative z-10">
|
||||
<div class="flex gap-4">
|
||||
{#if showDragHandle}
|
||||
|
||||
@@ -13,7 +13,11 @@ export const users = pgTable('user', {
|
||||
image: text('image'),
|
||||
password: text('password'),
|
||||
username: text('username').unique(),
|
||||
dashboardTheme: text('dashboard_theme').default('none')
|
||||
dashboardTheme: text('dashboard_theme').default('none'),
|
||||
dashboardColor: text('dashboard_color'),
|
||||
lastLogin: timestamp('last_login', { mode: 'date' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const accounts = pgTable(
|
||||
|
||||
@@ -5,6 +5,7 @@ type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
class ThemeStore {
|
||||
current = $state<Theme>('system');
|
||||
resolved = $state<ResolvedTheme>('light');
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
@@ -27,6 +28,8 @@ class ThemeStore {
|
||||
const isDark = this.current === 'dark' ||
|
||||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
this.resolved = isDark ? 'dark' : 'light';
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
@@ -35,11 +38,7 @@ 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';
|
||||
return this.resolved;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
|
||||
@@ -11,8 +11,10 @@ export function hexToRgba(hex: string, alpha: number): string {
|
||||
/**
|
||||
* Generate card style string with color, transparency, and blur
|
||||
*/
|
||||
export function getCardStyle(color: string | null): string {
|
||||
if (!color) return '';
|
||||
export function getCardStyle(color: string | null, fallbackColor?: string | null): string {
|
||||
const activeColor = color || fallbackColor;
|
||||
if (!activeColor) return '';
|
||||
|
||||
return `background-color: ${hexToRgba(color, 0.2)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
|
||||
const opacity = color ? 0.2 : 0.15;
|
||||
return `background-color: ${hexToRgba(activeColor, opacity)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
|
||||
export type ThemePattern = 'waves' | 'geometric' | 'dots' | 'none';
|
||||
export type ThemePattern = 'snow' | 'none';
|
||||
|
||||
export interface Theme {
|
||||
name: string;
|
||||
@@ -13,16 +13,8 @@ export const AVAILABLE_THEMES: Record<string, Theme> = {
|
||||
pattern: 'none'
|
||||
},
|
||||
waves: {
|
||||
name: 'Waves',
|
||||
pattern: 'waves'
|
||||
},
|
||||
geometric: {
|
||||
name: 'Geometric',
|
||||
pattern: 'geometric'
|
||||
},
|
||||
dots: {
|
||||
name: 'Dots',
|
||||
pattern: 'dots'
|
||||
name: 'Snow',
|
||||
pattern: 'snow'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
await db.update(wishlists)
|
||||
.set({ isFavorite: !isFavorite })
|
||||
.set({ isFavorite: !isFavorite, updatedAt: new Date() })
|
||||
.where(eq(wishlists.id, wishlistId));
|
||||
|
||||
return { success: true };
|
||||
@@ -171,7 +171,23 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ dashboardTheme: theme })
|
||||
.set({ dashboardTheme: theme, updatedAt: new Date() })
|
||||
.where(eq(users.id, session.user.id));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateDashboardColor: async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.id) {
|
||||
throw redirect(303, '/signin');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const color = formData.get('color') as string | null;
|
||||
|
||||
await db.update(users)
|
||||
.set({ dashboardColor: color, updatedAt: new Date() })
|
||||
.where(eq(users.id, session.user.id));
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -24,7 +24,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// For anonymous users, get color from localStorage
|
||||
function getInitialColor() {
|
||||
if (data.isAuthenticated) {
|
||||
return data.user?.dashboardColor || null;
|
||||
} else {
|
||||
// Anonymous user - get from localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('dashboardColor') || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let currentTheme = $state(getInitialTheme());
|
||||
let currentColor = $state(getInitialColor());
|
||||
|
||||
// Save to localStorage when theme changes for anonymous users
|
||||
function handleThemeUpdate(theme: string | null) {
|
||||
@@ -35,6 +49,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Save to localStorage when color changes for anonymous users
|
||||
function handleColorUpdate(color: string | null) {
|
||||
currentColor = color;
|
||||
|
||||
if (!data.isAuthenticated && typeof window !== 'undefined') {
|
||||
if (color) {
|
||||
localStorage.setItem('dashboardColor', color);
|
||||
} else {
|
||||
localStorage.removeItem('dashboardColor');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
|
||||
// Only owned wishlists for "My Wishlists"
|
||||
@@ -58,17 +85,23 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageContainer theme={currentTheme} themeColor={null}>
|
||||
<PageContainer theme={currentTheme} themeColor={currentColor}>
|
||||
<DashboardHeader
|
||||
userName={data.user?.name}
|
||||
userEmail={data.user?.email}
|
||||
dashboardTheme={currentTheme}
|
||||
dashboardColor={currentColor}
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
onThemeUpdate={handleThemeUpdate}
|
||||
onColorUpdate={handleColorUpdate}
|
||||
/>
|
||||
|
||||
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
|
||||
<LocalWishlistsSection isAuthenticated={data.isAuthenticated} />
|
||||
<LocalWishlistsSection
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
fallbackColor={currentColor}
|
||||
fallbackTheme={currentTheme}
|
||||
/>
|
||||
|
||||
{#if data.isAuthenticated}
|
||||
<!-- My Wishlists Section -->
|
||||
@@ -80,6 +113,8 @@
|
||||
emptyActionLabel={t.dashboard.emptyWishlistsAction}
|
||||
emptyActionHref="/"
|
||||
showCreateButton={true}
|
||||
fallbackColor={currentColor}
|
||||
fallbackTheme={currentTheme}
|
||||
>
|
||||
{#snippet actions(wishlist, unlocked)}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
@@ -135,6 +170,8 @@
|
||||
emptyMessage={t.dashboard.emptyClaimedWishlists}
|
||||
emptyDescription={t.dashboard.emptyClaimedWishlistsDescription}
|
||||
hideIfEmpty={true}
|
||||
fallbackColor={currentColor}
|
||||
fallbackTheme={currentTheme}
|
||||
>
|
||||
{#snippet actions(wishlist, unlocked)}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
@@ -189,6 +226,8 @@
|
||||
items={savedWishlists()}
|
||||
emptyMessage={t.dashboard.emptySavedWishlists}
|
||||
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
|
||||
fallbackColor={currentColor}
|
||||
fallbackTheme={currentTheme}
|
||||
>
|
||||
{#snippet actions(saved, unlocked)}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import type { PageData } from "./$types";
|
||||
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
|
||||
import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte";
|
||||
@@ -19,6 +17,7 @@
|
||||
import { getCardStyle } from "$lib/utils/colors";
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
import SearchBar from "$lib/components/ui/SearchBar.svelte";
|
||||
import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -39,9 +38,9 @@
|
||||
<Navigation
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
showDashboardLink={true}
|
||||
color={data.wishlist.color}
|
||||
/>
|
||||
|
||||
<!-- Header -->
|
||||
<Card style={headerCardStyle}>
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -55,7 +54,6 @@
|
||||
</div>
|
||||
{#if data.isAuthenticated}
|
||||
{#if data.isClaimed}
|
||||
<!-- User has claimed this wishlist - show claimed status -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -64,7 +62,6 @@
|
||||
{t.wishlist.youClaimedThis}
|
||||
</Button>
|
||||
{:else if data.isSaved}
|
||||
<!-- User has saved but not claimed - show unsave button -->
|
||||
<form method="POST" action="?/unsaveWishlist" use:enhance>
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -76,7 +73,6 @@
|
||||
</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Not saved - show save button -->
|
||||
<form method="POST" action="?/saveWishlist" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
@@ -101,16 +97,14 @@
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Search Bar -->
|
||||
{#if data.wishlist.items && data.wishlist.items.length > 0}
|
||||
<SearchBar bind:value={searchQuery} />
|
||||
{/if}
|
||||
|
||||
<!-- Items List -->
|
||||
<div class="space-y-4">
|
||||
{#if filteredItems.length > 0}
|
||||
{#each filteredItems as item}
|
||||
<WishlistItem {item} theme={data.wishlist.theme}>
|
||||
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
|
||||
<ReservationButton
|
||||
itemId={item.id}
|
||||
isReserved={item.isReserved}
|
||||
@@ -121,16 +115,18 @@
|
||||
</WishlistItem>
|
||||
{/each}
|
||||
{:else if data.wishlist.items && data.wishlist.items.length > 0}
|
||||
<Card>
|
||||
<CardContent class="p-12">
|
||||
<Card style={headerCardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
|
||||
<CardContent class="p-12 relative z-10">
|
||||
<EmptyState
|
||||
message="No wishes match your search."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="p-12">
|
||||
<Card style={headerCardStyle} class="relative overflow-hidden">
|
||||
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} showPattern={false} />
|
||||
<CardContent class="p-12 relative z-10">
|
||||
<EmptyState
|
||||
message={t.wishlist.emptyWishes}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
let editFormElement = $state<HTMLElement | null>(null);
|
||||
let searchQuery = $state("");
|
||||
let currentTheme = $state(data.wishlist.theme || 'none');
|
||||
let currentColor = $state(data.wishlist.color);
|
||||
|
||||
let items = $state<Item[]>([]);
|
||||
|
||||
@@ -111,19 +112,25 @@
|
||||
currentTheme = theme || 'none';
|
||||
await wishlistUpdates.updateTheme(theme);
|
||||
}
|
||||
|
||||
async function handleColorUpdate(color: string | null) {
|
||||
currentColor = color;
|
||||
await wishlistUpdates.updateColor(color);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageContainer maxWidth="4xl" theme={currentTheme} themeColor={data.wishlist.color}>
|
||||
<PageContainer maxWidth="4xl" theme={currentTheme} themeColor={currentColor}>
|
||||
<Navigation
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
showDashboardLink={true}
|
||||
color={currentColor}
|
||||
/>
|
||||
|
||||
<WishlistHeader
|
||||
wishlist={data.wishlist}
|
||||
onTitleUpdate={wishlistUpdates.updateTitle}
|
||||
onDescriptionUpdate={wishlistUpdates.updateDescription}
|
||||
onColorUpdate={wishlistUpdates.updateColor}
|
||||
onColorUpdate={handleColorUpdate}
|
||||
onEndDateUpdate={wishlistUpdates.updateEndDate}
|
||||
onThemeUpdate={handleThemeUpdate}
|
||||
/>
|
||||
@@ -131,6 +138,7 @@
|
||||
<ShareLinks
|
||||
publicUrl={data.publicUrl}
|
||||
ownerUrl="/wishlist/{data.wishlist.ownerToken}/edit"
|
||||
wishlistColor={currentColor}
|
||||
/>
|
||||
|
||||
<ClaimWishlistSection
|
||||
@@ -148,7 +156,7 @@
|
||||
|
||||
{#if showAddForm}
|
||||
<div bind:this={addFormElement}>
|
||||
<AddItemForm onSuccess={handleItemAdded} />
|
||||
<AddItemForm onSuccess={handleItemAdded} wishlistColor={currentColor} wishlistTheme={currentTheme} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -162,6 +170,8 @@
|
||||
currentPosition={items.findIndex(item => item.id === editingItem.id) + 1}
|
||||
totalItems={items.length}
|
||||
onPositionChange={handlePositionChange}
|
||||
wishlistColor={currentColor}
|
||||
wishlistTheme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -176,6 +186,7 @@
|
||||
onStartEditing={startEditing}
|
||||
onReorder={handleReorder}
|
||||
theme={currentTheme}
|
||||
wishlistColor={currentColor}
|
||||
/>
|
||||
|
||||
<DangerZone bind:unlocked={rearranging} />
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
2782
static/themes/snow/bgbottom.svg
Normal file
|
After Width: | Height: | Size: 130 KiB |
2783
static/themes/snow/bgtop.svg
Normal file
|
After Width: | Height: | Size: 130 KiB |
1695
static/themes/snow/item.svg
Normal file
|
After Width: | Height: | Size: 78 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |