Fase 0: scaffold monorepo, SvelteKit skeleton, and dev infrastructure
- Turborepo + pnpm workspaces with apps/web and packages/types - SvelteKit app: Tailwind, Paraglide i18n (en/es), keycloak-js, Supabase client, auth/collective stores, PWA service worker skeleton, all route placeholders - packages/types: domain types (User, Collective, ShoppingList, etc.) and database.ts placeholder for generated types - Justfile with dev, db-*, kc-*, build, backup, restore, deploy recipes - infra/docker-compose.dev.yml: Postgres 15, GoTrue, PostgREST, Realtime v2.83, Storage, Kong (port 8001), Studio, Keycloak 24 - infra/docker-compose.prod.yml, kong.yml, db-init scripts, backup/deploy scripts - keycloak/realm-export.json with colectivo realm and 5 dev test users - supabase/config.toml and seed.sql with sample collective and items - GitHub Actions: ci.yml (lint+typecheck+build) and deploy.yml (GHCR + SSH) - .env.example documenting all required variables - Fixed docker-compose issues: Studio image tag, Kong port conflict (8001), internal role passwords init script, Realtime METRICS_JWT_SECRET/APP_NAME Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
45
apps/web/src/app.css
Normal file
45
apps/web/src/app.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* Inter font */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Design tokens — light mode */
|
||||
:root {
|
||||
--background: 248 250 252; /* slate-50 */
|
||||
--surface: 255 255 255; /* white */
|
||||
--surface-raised: 241 245 249; /* slate-100 */
|
||||
--border: 226 232 240; /* slate-200 */
|
||||
--text-primary: 15 23 42; /* slate-900 */
|
||||
--text-secondary: 100 116 139; /* slate-500 */
|
||||
--text-muted: 148 163 184; /* slate-400 */
|
||||
}
|
||||
|
||||
/* Design tokens — dark mode */
|
||||
.dark {
|
||||
--background: 15 23 42; /* slate-950 */
|
||||
--surface: 30 41 59; /* slate-800 → closer to slate-900 card */
|
||||
--surface-raised: 51 65 85; /* slate-700 */
|
||||
--border: 51 65 85; /* slate-700 */
|
||||
--text-primary: 248 250 252; /* slate-50 */
|
||||
--text-secondary: 148 163 184; /* slate-400 */
|
||||
--text-muted: 100 116 139; /* slate-500 */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--text-primary));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
16
apps/web/src/app.d.ts
vendored
Normal file
16
apps/web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
import type { KeycloakUser } from '$lib/auth';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: KeycloakUser | null;
|
||||
}
|
||||
// interface Error {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
13
apps/web/src/app.html
Normal file
13
apps/web/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="%paraglide.lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
78
apps/web/src/lib/auth.ts
Normal file
78
apps/web/src/lib/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM, PUBLIC_KEYCLOAK_CLIENT_ID } from '$env/static/public';
|
||||
|
||||
export interface KeycloakUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
preferredUsername: string;
|
||||
}
|
||||
|
||||
let keycloak: Keycloak | null = null;
|
||||
|
||||
export function getKeycloak(): Keycloak {
|
||||
if (!keycloak) {
|
||||
keycloak = new Keycloak({
|
||||
url: PUBLIC_KEYCLOAK_URL,
|
||||
realm: PUBLIC_KEYCLOAK_REALM,
|
||||
clientId: PUBLIC_KEYCLOAK_CLIENT_ID
|
||||
});
|
||||
}
|
||||
return keycloak;
|
||||
}
|
||||
|
||||
export async function initAuth(): Promise<boolean> {
|
||||
if (!browser) return false;
|
||||
|
||||
const kc = getKeycloak();
|
||||
|
||||
const authenticated = await kc.init({
|
||||
onLoad: 'check-sso',
|
||||
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
|
||||
// Safari blocks third-party cookies in iframes — must be false
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: 'S256'
|
||||
});
|
||||
|
||||
if (authenticated) {
|
||||
// Schedule proactive token refresh (60s before expiry)
|
||||
scheduleTokenRefresh(kc);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
function scheduleTokenRefresh(kc: Keycloak): void {
|
||||
setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await kc.updateToken(60);
|
||||
} catch {
|
||||
// Token refresh failed — redirect to login
|
||||
kc.login();
|
||||
}
|
||||
},
|
||||
30 * 1000 // check every 30s
|
||||
);
|
||||
}
|
||||
|
||||
export function getUser(kc: Keycloak): KeycloakUser | null {
|
||||
if (!kc.authenticated || !kc.tokenParsed) return null;
|
||||
|
||||
const token = kc.tokenParsed as Record<string, unknown>;
|
||||
return {
|
||||
id: token['sub'] as string,
|
||||
email: token['email'] as string,
|
||||
displayName: (token['name'] as string) ?? (token['preferred_username'] as string),
|
||||
preferredUsername: token['preferred_username'] as string
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(): Promise<void> {
|
||||
getKeycloak().login();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
getKeycloak().logout({ redirectUri: window.location.origin });
|
||||
}
|
||||
6
apps/web/src/lib/i18n.ts
Normal file
6
apps/web/src/lib/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createI18n } from '@inlang/paraglide-sveltekit';
|
||||
import * as runtime from '$lib/paraglide/runtime';
|
||||
|
||||
export const i18n = createI18n(runtime, {
|
||||
defaultLanguageTag: 'en'
|
||||
});
|
||||
10
apps/web/src/lib/stores/auth.ts
Normal file
10
apps/web/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type Keycloak from 'keycloak-js';
|
||||
import type { KeycloakUser } from '$lib/auth';
|
||||
|
||||
export const keycloak = writable<Keycloak | null>(null);
|
||||
export const isAuthenticated = writable<boolean>(false);
|
||||
export const currentUser = writable<KeycloakUser | null>(null);
|
||||
export const authLoading = writable<boolean>(true);
|
||||
|
||||
export const token = derived(keycloak, ($kc) => $kc?.token ?? null);
|
||||
22
apps/web/src/lib/stores/collective.ts
Normal file
22
apps/web/src/lib/stores/collective.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Collective {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CollectiveMember {
|
||||
user_id: string;
|
||||
collective_id: string;
|
||||
role: 'admin' | 'member' | 'guest';
|
||||
display_name: string;
|
||||
avatar_type: 'initials' | 'emoji' | 'upload';
|
||||
avatar_emoji: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export const currentCollective = writable<Collective | null>(null);
|
||||
export const collectiveMembers = writable<CollectiveMember[]>([]);
|
||||
export const userCollectives = writable<Collective[]>([]);
|
||||
44
apps/web/src/lib/supabase.ts
Normal file
44
apps/web/src/lib/supabase.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import type { Database } from '@colectivo/types';
|
||||
|
||||
let supabase: SupabaseClient<Database> | null = null;
|
||||
|
||||
export function getSupabase(): SupabaseClient<Database> {
|
||||
if (!supabase) {
|
||||
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
// Auth is handled by Keycloak — disable Supabase GoTrue auth flows
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false
|
||||
},
|
||||
global: {
|
||||
headers: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Keycloak access token on the Supabase client so RLS policies
|
||||
* resolve auth.uid() to the Keycloak sub claim.
|
||||
*/
|
||||
export function setSupabaseToken(accessToken: string): void {
|
||||
const client = getSupabase();
|
||||
// @ts-expect-error — internal API to inject a custom JWT
|
||||
client.rest.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
if (browser) {
|
||||
// Also set on the realtime client
|
||||
// @ts-expect-error
|
||||
client.realtime.setAuth(accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSupabaseToken(): void {
|
||||
const client = getSupabase();
|
||||
// @ts-expect-error
|
||||
delete client.rest.headers['Authorization'];
|
||||
}
|
||||
83
apps/web/src/routes/(app)/+layout.svelte
Normal file
83
apps/web/src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { logout } from '$lib/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/lists', label: () => m.nav_lists(), icon: '🛒' },
|
||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: '✓' },
|
||||
{ href: '/notes', label: () => m.nav_notes(), icon: '📄' },
|
||||
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
|
||||
];
|
||||
|
||||
$: activePath = $page.url.pathname;
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="flex h-screen items-center justify-center bg-background">
|
||||
<span class="text-text-secondary text-sm">{m.loading()}</span>
|
||||
</div>
|
||||
{:else if !$isAuthenticated}
|
||||
<!-- Keycloak redirect is triggered in root layout; show nothing -->
|
||||
{:else}
|
||||
<div class="flex h-screen overflow-hidden bg-background">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
|
||||
<!-- Collective name -->
|
||||
<div class="flex h-14 items-center gap-2 border-b border-slate-200 px-4 dark:border-slate-700">
|
||||
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
|
||||
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{$currentCollective?.name ?? m.app_name()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{activePath.startsWith(item.href)
|
||||
? 'bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-slate-50'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-50'}"
|
||||
>
|
||||
<span class="text-base">{item.icon}</span>
|
||||
{item.label()}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User + settings -->
|
||||
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700 dark:bg-slate-600 dark:text-slate-200"
|
||||
>
|
||||
{$currentUser?.displayName?.slice(0, 2).toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{$currentUser?.displayName ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
|
||||
aria-label={m.nav_settings()}
|
||||
>
|
||||
⚙
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex flex-1 flex-col overflow-hidden">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
12
apps/web/src/routes/(app)/lists/+page.svelte
Normal file
12
apps/web/src/routes/(app)/lists/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.lists_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 2a: shopping lists content -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/notes/+page.svelte
Normal file
12
apps/web/src/routes/(app)/notes/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.notes_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 3: notes content -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/search/+page.svelte
Normal file
12
apps/web/src/routes/(app)/search/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.search_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 4: search content -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/settings/+page.svelte
Normal file
12
apps/web/src/routes/(app)/settings/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 1: settings content (display name, avatar, language) -->
|
||||
</div>
|
||||
</div>
|
||||
12
apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
12
apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.tasks_title()}</h1>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- Fase 3: tasks content -->
|
||||
</div>
|
||||
</div>
|
||||
51
apps/web/src/routes/+layout.svelte
Normal file
51
apps/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { initAuth, getKeycloak, getUser } from '$lib/auth';
|
||||
import { setSupabaseToken, clearSupabaseToken } from '$lib/supabase';
|
||||
import { keycloak, isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
|
||||
const PUBLIC_ROUTES = ['/invitation'];
|
||||
|
||||
onMount(async () => {
|
||||
const kc = getKeycloak();
|
||||
keycloak.set(kc);
|
||||
|
||||
const authenticated = await initAuth();
|
||||
isAuthenticated.set(authenticated);
|
||||
|
||||
if (authenticated) {
|
||||
const user = getUser(kc);
|
||||
currentUser.set(user);
|
||||
if (kc.token) setSupabaseToken(kc.token);
|
||||
|
||||
// Keep token in sync with Supabase client
|
||||
kc.onTokenRefreshed = () => {
|
||||
if (kc.token) setSupabaseToken(kc.token);
|
||||
};
|
||||
kc.onAuthLogout = () => {
|
||||
clearSupabaseToken();
|
||||
isAuthenticated.set(false);
|
||||
currentUser.set(null);
|
||||
};
|
||||
}
|
||||
|
||||
authLoading.set(false);
|
||||
|
||||
// Redirect unauthenticated users unless on a public route
|
||||
if (!authenticated) {
|
||||
const isPublic = PUBLIC_ROUTES.some((r) => $page.url.pathname.startsWith(r));
|
||||
if (!isPublic) {
|
||||
kc.login();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ParaglideJS {i18n}>
|
||||
<slot />
|
||||
</ParaglideJS>
|
||||
16
apps/web/src/routes/+page.svelte
Normal file
16
apps/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { isAuthenticated, authLoading } from '$lib/stores/auth';
|
||||
|
||||
onMount(() => {
|
||||
const unsubLoading = authLoading.subscribe((loading) => {
|
||||
if (!loading) {
|
||||
unsubLoading();
|
||||
goto('/lists', { replaceState: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Redirect root to /lists — handled in onMount after auth resolves -->
|
||||
25
apps/web/src/routes/onboarding/+page.svelte
Normal file
25
apps/web/src/routes/onboarding/+page.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
|
||||
<div class="w-full max-w-sm space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Fase 1: collective creation / join form -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
class="w-full rounded-md bg-slate-900 px-4 py-3 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
{m.onboarding_create_collective()}
|
||||
</button>
|
||||
<button
|
||||
class="w-full rounded-md border border-slate-200 bg-surface px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.onboarding_join_collective()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
50
apps/web/src/service-worker.ts
Normal file
50
apps/web/src/service-worker.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { build, files, version } from '$service-worker';
|
||||
import { precacheAndRoute } from 'workbox-precaching';
|
||||
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
|
||||
import { BackgroundSyncPlugin } from 'workbox-background-sync';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const CACHE_NAME = `colectivo-${version}`;
|
||||
const ASSETS = [...build, ...files];
|
||||
|
||||
// Precache all static assets
|
||||
precacheAndRoute(ASSETS.map((url) => ({ url, revision: version })));
|
||||
|
||||
// Cache-first for static assets (JS, CSS, fonts, icons)
|
||||
registerRoute(
|
||||
({ request }) =>
|
||||
request.destination === 'style' ||
|
||||
request.destination === 'script' ||
|
||||
request.destination === 'font' ||
|
||||
request.destination === 'image',
|
||||
new CacheFirst({ cacheName: `${CACHE_NAME}-assets` })
|
||||
);
|
||||
|
||||
// Network-first for app routes (HTML navigation)
|
||||
registerRoute(
|
||||
new NavigationRoute(
|
||||
new NetworkFirst({
|
||||
cacheName: `${CACHE_NAME}-pages`,
|
||||
networkTimeoutSeconds: 3
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Background Sync for pending offline operations
|
||||
const bgSyncPlugin = new BackgroundSyncPlugin('pending-ops-queue', {
|
||||
maxRetentionTime: 24 * 60 // 24 hours
|
||||
});
|
||||
|
||||
// Listen for messages from the app
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user