feat(fase-1): auth, collective management, and DB migrations
- SQL migrations: users, collectives, collective_members, collective_invitations,
full RLS policies, is_active_member/is_member/is_admin helpers,
accept_invitation SECURITY DEFINER function, admin auto-promote trigger,
avatars storage bucket
- Auth refactor: replace keycloak-js with Supabase OAuth (GoTrue OIDC proxy,
Option B). signInWithOAuth({ provider: 'keycloak' }) + PKCE flow
- GoTrue config: GOTRUE_EXTERNAL_KEYCLOAK_URL + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI
added to docker-compose.dev.yml; Keycloak realm updated with GoTrue callback URI
- New routes: /auth/callback, /invitation/[token], /(app)/collective/manage
- Functional onboarding: create collective or join via invite link
- Full settings page: display name (debounced), avatar (initials/emoji/upload),
language switcher, Keycloak account console link
- Components: Avatar.svelte (initials/emoji/upload with fallback),
ImageCropper.svelte (cropperjs, 1:1 crop, lazy-loaded)
- i18n: added all Fase 1 message keys (en + es); renamed 'delete' → 'action_delete'
(JS reserved word); fixed plugin-m-function-matcher major-version URL format
- Database types: manually seeded packages/types/src/database.ts from migrations
- .env.development: committed public dev defaults for SvelteKit type checking
- CLAUDE.md: documented /etc/hosts requirement for keycloak hostname
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
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 { isAuthenticated, authLoading, displayName } from '$lib/stores/auth';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const navItems = [
|
||||
@@ -13,25 +16,63 @@
|
||||
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
|
||||
];
|
||||
|
||||
$: activePath = $page.url.pathname;
|
||||
let collectiveSwitcherOpen = $state(false);
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
|
||||
function switchCollective(id: string) {
|
||||
const c = $userCollectives.find((col) => col.id === id);
|
||||
if (c) {
|
||||
currentCollective.set(c);
|
||||
localStorage.setItem('activeCollectiveId', c.id);
|
||||
collectiveSwitcherOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="flex h-screen items-center justify-center bg-background">
|
||||
<span class="text-text-secondary text-sm">{m.loading()}</span>
|
||||
<span class="text-sm text-text-secondary">{m.loading()}</span>
|
||||
</div>
|
||||
{:else if !$isAuthenticated}
|
||||
<!-- Keycloak redirect is triggered in root layout; show nothing -->
|
||||
{:else}
|
||||
{:else if $isAuthenticated}
|
||||
<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>
|
||||
|
||||
<!-- Collective header + switcher -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-14 w-full items-center gap-2 border-b border-slate-200 px-4 text-left
|
||||
hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
onclick={() => (collectiveSwitcherOpen = !collectiveSwitcherOpen)}
|
||||
aria-expanded={collectiveSwitcherOpen}
|
||||
>
|
||||
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{$currentCollective?.name ?? m.app_name()}
|
||||
</span>
|
||||
{#if $userCollectives.length > 1}
|
||||
<span class="text-xs text-slate-400">⌄</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if collectiveSwitcherOpen && $userCollectives.length > 1}
|
||||
<div
|
||||
class="absolute left-0 right-0 top-full z-50 border-b border-slate-200 bg-surface shadow-md dark:border-slate-700"
|
||||
>
|
||||
{#each $userCollectives as c}
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm
|
||||
hover:bg-slate-50 dark:hover:bg-slate-800
|
||||
{c.id === $currentCollective?.id ? 'font-semibold text-slate-900 dark:text-slate-50' : 'text-slate-600 dark:text-slate-400'}"
|
||||
onclick={() => switchCollective(c.id)}
|
||||
>
|
||||
<span class="text-base">{c.emoji}</span>
|
||||
<span class="truncate">{c.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
@@ -50,18 +91,13 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User + settings -->
|
||||
<!-- User footer -->
|
||||
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
<Avatar name={$displayName} size={32} />
|
||||
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{$currentUser?.displayName ?? ''}
|
||||
{$displayName}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -77,7 +113,7 @@
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex flex-1 flex-col overflow-hidden">
|
||||
<slot />
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
21
apps/web/src/routes/(app)/+layout.ts
Normal file
21
apps/web/src/routes/(app)/+layout.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { login } from '$lib/auth';
|
||||
|
||||
// No SSR — this is a client-rendered PWA
|
||||
export const ssr = false;
|
||||
|
||||
export async function load() {
|
||||
if (!browser) return {};
|
||||
|
||||
const {
|
||||
data: { session }
|
||||
} = await getSupabase().auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
// Not authenticated — redirect to Keycloak
|
||||
await login();
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
252
apps/web/src/routes/(app)/collective/manage/+page.svelte
Normal file
252
apps/web/src/routes/(app)/collective/manage/+page.svelte
Normal file
@@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective, collectiveMembers } from '$lib/stores/collective';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Member = {
|
||||
user_id: string;
|
||||
role: 'admin' | 'member' | 'guest';
|
||||
display_name: string;
|
||||
avatar_type: 'initials' | 'emoji' | 'upload';
|
||||
avatar_emoji: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
let members = $state<Member[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let inviteRole = $state<'member' | 'guest'>('member');
|
||||
let generatedLink = $state<string | null>(null);
|
||||
let generating = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
|
||||
const isAdmin = $derived(myRole === 'admin');
|
||||
|
||||
onMount(async () => {
|
||||
await loadMembers();
|
||||
});
|
||||
|
||||
async function loadMembers() {
|
||||
if (!$currentCollective) return;
|
||||
loading = true;
|
||||
|
||||
const { data, error: fetchError } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.select(`
|
||||
user_id, role,
|
||||
users(display_name, avatar_type, avatar_emoji, avatar_url)
|
||||
`)
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
.order('joined_at');
|
||||
|
||||
if (fetchError) {
|
||||
error = fetchError.message;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
members = (data ?? []).map((row) => {
|
||||
const u = row.users as { display_name: string; avatar_type: string; avatar_emoji: string | null; avatar_url: string | null } | null;
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
role: row.role as Member['role'],
|
||||
display_name: u?.display_name ?? '',
|
||||
avatar_type: (u?.avatar_type ?? 'initials') as Member['avatar_type'],
|
||||
avatar_emoji: u?.avatar_emoji ?? null,
|
||||
avatar_url: u?.avatar_url ?? null
|
||||
};
|
||||
});
|
||||
collectiveMembers.set(members.map((m) => ({ ...m, collective_id: $currentCollective!.id })));
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function changeRole(userId: string, newRole: 'admin' | 'member' | 'guest') {
|
||||
if (!$currentCollective) return;
|
||||
const { error: updateError } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.update({ role: newRole })
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (updateError) {
|
||||
error = updateError.message;
|
||||
return;
|
||||
}
|
||||
members = members.map((m) => m.user_id === userId ? { ...m, role: newRole } : m);
|
||||
}
|
||||
|
||||
async function removeMember(userId: string) {
|
||||
if (!$currentCollective) return;
|
||||
const { error: deleteError } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.delete()
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (deleteError) {
|
||||
error = deleteError.message;
|
||||
return;
|
||||
}
|
||||
members = members.filter((m) => m.user_id !== userId);
|
||||
}
|
||||
|
||||
async function generateInviteLink() {
|
||||
if (!$currentCollective) return;
|
||||
generating = true;
|
||||
generatedLink = null;
|
||||
|
||||
const { data, error: insertError } = await getSupabase()
|
||||
.from('collective_invitations')
|
||||
.insert({
|
||||
collective_id: $currentCollective.id,
|
||||
role: inviteRole,
|
||||
created_by: $currentUser!.id
|
||||
})
|
||||
.select('token')
|
||||
.single();
|
||||
|
||||
if (insertError || !data) {
|
||||
error = insertError?.message ?? 'Failed to generate link.';
|
||||
generating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
generatedLink = `${window.location.origin}/invitation/${data.token}`;
|
||||
generating = false;
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!generatedLink) return;
|
||||
await navigator.clipboard.writeText(generatedLink);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
const ROLES: Array<'admin' | 'member' | 'guest'> = ['admin', 'member', 'guest'];
|
||||
|
||||
function roleLabel(role: 'admin' | 'member' | 'guest'): string {
|
||||
if (role === 'admin') return m.role_admin();
|
||||
if (role === 'member') return m.role_member();
|
||||
return m.role_guest();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<h1 class="mb-6 text-xl font-bold text-slate-900 dark:text-slate-50">
|
||||
{m.manage_title()}
|
||||
</h1>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-4 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Members list -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.manage_members()}
|
||||
</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{#each members as member}
|
||||
<li class="flex items-center gap-3 py-3">
|
||||
<Avatar
|
||||
name={member.display_name}
|
||||
type={member.avatar_type}
|
||||
emoji={member.avatar_emoji}
|
||||
src={member.avatar_url}
|
||||
size={36}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-900 dark:text-slate-50">
|
||||
{member.display_name}
|
||||
{#if member.user_id === $currentUser?.id}
|
||||
<span class="ml-1 text-text-secondary">({m.manage_you()})</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isAdmin && member.user_id !== $currentUser?.id}
|
||||
<select
|
||||
value={member.role}
|
||||
onchange={(e) => changeRole(member.user_id, e.currentTarget.value as Member['role'])}
|
||||
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-xs
|
||||
dark:border-slate-600 dark:bg-slate-800"
|
||||
>
|
||||
{#each ROLES as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
onclick={() => removeMember(member.user_id)}
|
||||
class="rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
|
||||
dark:hover:bg-red-900/20"
|
||||
aria-label={m.manage_remove()}
|
||||
>✕</button>
|
||||
{:else}
|
||||
<span class="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600
|
||||
dark:bg-slate-700 dark:text-slate-400">
|
||||
{roleLabel(member.role)}
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Invite link generator (admins only) -->
|
||||
{#if isAdmin}
|
||||
<section class="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
|
||||
<h2 class="mb-3 text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.manage_invite_title()}
|
||||
</h2>
|
||||
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<label for="invite-role" class="text-sm text-slate-700 dark:text-slate-300">{m.manage_invite_role()}</label>
|
||||
<select
|
||||
id="invite-role"
|
||||
bind:value={inviteRole}
|
||||
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-sm
|
||||
dark:border-slate-600 dark:bg-slate-800"
|
||||
>
|
||||
<option value="member">{m.role_member()}</option>
|
||||
<option value="guest">{m.role_guest()}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={generateInviteLink}
|
||||
disabled={generating}
|
||||
class="mb-3 w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
|
||||
hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{generating ? m.loading() : m.manage_generate_link()}
|
||||
</button>
|
||||
|
||||
{#if generatedLink}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2
|
||||
dark:border-slate-700 dark:bg-slate-800">
|
||||
<code class="min-w-0 flex-1 truncate text-xs text-slate-600 dark:text-slate-400">
|
||||
{generatedLink}
|
||||
</code>
|
||||
<button
|
||||
onclick={copyLink}
|
||||
class="flex-shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-700
|
||||
hover:bg-slate-200 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
{copied ? m.manage_copied() : m.manage_copy()}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-text-secondary">{m.manage_link_expires()}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,12 +1,281 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
PUBLIC_KEYCLOAK_URL,
|
||||
PUBLIC_KEYCLOAK_REALM
|
||||
} from '$env/static/public';
|
||||
|
||||
// Profile state loaded from public.users
|
||||
let displayNameInput = $state('');
|
||||
let language = $state<'en' | 'es'>('en');
|
||||
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
|
||||
let avatarEmoji = $state<string | null>(null);
|
||||
let avatarUrl = $state<string | null>(null);
|
||||
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Cropper
|
||||
let cropperFile = $state<File | null>(null);
|
||||
let uploading = $state(false);
|
||||
|
||||
const EMOJI_GRID = [
|
||||
'😀','😎','🥳','🤓','👻','🐱','🐶','🦊',
|
||||
'🐸','🦁','🐻','🐼','🐨','🐯','🦄','🐧',
|
||||
'🦋','🌈','⭐','🌙','☀️','🌊','🌴','🌺',
|
||||
'🍕','🍦','🎸','🎯','🚀','⚽','🎮','🎨'
|
||||
];
|
||||
|
||||
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
|
||||
|
||||
onMount(async () => {
|
||||
await loadProfile();
|
||||
});
|
||||
|
||||
async function loadProfile() {
|
||||
if (!$currentUser) return;
|
||||
|
||||
const { data } = await getSupabase()
|
||||
.from('users')
|
||||
.select('display_name, language, avatar_type, avatar_emoji, avatar_url')
|
||||
.eq('id', $currentUser.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
displayNameInput = data.display_name;
|
||||
language = data.language as 'en' | 'es';
|
||||
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
|
||||
avatarEmoji = data.avatar_emoji;
|
||||
avatarUrl = data.avatar_url;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNameSave() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(saveName, 700);
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
if (!$currentUser) return;
|
||||
saving = true;
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ display_name: displayNameInput.trim() })
|
||||
.eq('id', $currentUser.id);
|
||||
saving = false;
|
||||
saved = true;
|
||||
setTimeout(() => (saved = false), 2000);
|
||||
}
|
||||
|
||||
async function selectEmojiAvatar(emoji: string) {
|
||||
avatarEmoji = emoji;
|
||||
avatarType = 'emoji';
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ avatar_type: 'emoji', avatar_emoji: emoji })
|
||||
.eq('id', $currentUser!.id);
|
||||
}
|
||||
|
||||
async function selectInitialsAvatar() {
|
||||
avatarType = 'initials';
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ avatar_type: 'initials', avatar_emoji: null })
|
||||
.eq('id', $currentUser!.id);
|
||||
}
|
||||
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) cropperFile = file;
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function onCrop(blob: Blob) {
|
||||
cropperFile = null;
|
||||
if (!$currentUser) return;
|
||||
uploading = true;
|
||||
|
||||
const path = `${$currentUser.id}/avatar.webp`;
|
||||
const { error: uploadError } = await getSupabase()
|
||||
.storage
|
||||
.from('avatars')
|
||||
.upload(path, blob, { upsert: true, contentType: 'image/webp' });
|
||||
|
||||
if (uploadError) {
|
||||
uploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: urlData } = await getSupabase()
|
||||
.storage
|
||||
.from('avatars')
|
||||
.createSignedUrl(path, 60 * 60 * 24 * 7); // 7 days
|
||||
|
||||
const signedUrl = urlData?.signedUrl ?? null;
|
||||
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ avatar_type: 'upload', avatar_url: signedUrl })
|
||||
.eq('id', $currentUser.id);
|
||||
|
||||
avatarType = 'upload';
|
||||
avatarUrl = signedUrl;
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
async function switchLanguage(lang: 'en' | 'es') {
|
||||
language = lang;
|
||||
setLanguageTag(lang);
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ language: lang })
|
||||
.eq('id', $currentUser!.id);
|
||||
}
|
||||
</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 class="mx-auto max-w-md space-y-8">
|
||||
|
||||
<!-- Avatar preview -->
|
||||
<div class="flex justify-center">
|
||||
<Avatar
|
||||
name={displayNameInput || $currentUser?.email || ''}
|
||||
type={avatarType}
|
||||
emoji={avatarEmoji}
|
||||
src={avatarUrl}
|
||||
size={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Display name -->
|
||||
<section>
|
||||
<label for="display-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.settings_display_name()}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input id="display-name"
|
||||
type="text"
|
||||
bind:value={displayNameInput}
|
||||
oninput={scheduleNameSave}
|
||||
maxlength="60"
|
||||
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
{#if saving}
|
||||
<span class="absolute right-3 top-2 text-xs text-text-secondary">{m.settings_saving()}</span>
|
||||
{:else if saved}
|
||||
<span class="absolute right-3 top-2 text-xs text-emerald-600">✓</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Avatar -->
|
||||
<section>
|
||||
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.settings_avatar()}
|
||||
</p>
|
||||
|
||||
<!-- Mode tabs -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each [['initials', m.settings_avatar_initials()], ['emoji', m.settings_avatar_emoji()], ['upload', m.settings_avatar_upload()]] as [mode, label]}
|
||||
<button
|
||||
onclick={() => mode === 'initials' ? selectInitialsAvatar() : avatarType = mode as typeof avatarType}
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{avatarType === mode
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if avatarType === 'emoji'}
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_GRID as e}
|
||||
<button
|
||||
onclick={() => selectEmojiAvatar(e)}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl
|
||||
{avatarEmoji === e ? 'bg-slate-900 dark:bg-slate-100' : 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if avatarType === 'upload'}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
|
||||
border-slate-300 px-4 py-3 text-sm text-slate-600 hover:border-slate-500
|
||||
dark:border-slate-600 dark:text-slate-400"
|
||||
>
|
||||
<span>{uploading ? m.loading() : m.settings_avatar_upload_btn()}</span>
|
||||
<input type="file" accept="image/*" class="hidden" onchange={onFileInput} />
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<section>
|
||||
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.settings_language()}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#each [['en', 'English'], ['es', 'Español']] as [code, label]}
|
||||
<button
|
||||
onclick={() => switchLanguage(code as 'en' | 'es')}
|
||||
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
{language === code
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Account -->
|
||||
<section class="border-t border-slate-200 pt-6 dark:border-slate-700">
|
||||
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.settings_account()}
|
||||
</p>
|
||||
<a
|
||||
href={keycloakAccountUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mb-3 flex items-center gap-2 text-sm text-slate-600 underline dark:text-slate-400"
|
||||
>
|
||||
{m.settings_keycloak_link()} ↗
|
||||
</a>
|
||||
<button
|
||||
onclick={logout}
|
||||
class="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600
|
||||
hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
{m.settings_logout()}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if cropperFile}
|
||||
<ImageCropper
|
||||
file={cropperFile}
|
||||
{onCrop}
|
||||
onCancel={() => (cropperFile = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3,47 +3,76 @@
|
||||
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 { getSupabase } from '$lib/supabase';
|
||||
import { login } from '$lib/auth';
|
||||
import { currentUser, authLoading } from '$lib/stores/auth';
|
||||
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
|
||||
const PUBLIC_ROUTES = ['/invitation'];
|
||||
// Routes that don't require authentication
|
||||
const PUBLIC_ROUTES = ['/auth', '/invitation'];
|
||||
|
||||
onMount(async () => {
|
||||
const kc = getKeycloak();
|
||||
keycloak.set(kc);
|
||||
function isPublicRoute(pathname: string): boolean {
|
||||
return PUBLIC_ROUTES.some((r) => pathname.startsWith(r));
|
||||
}
|
||||
|
||||
const authenticated = await initAuth();
|
||||
isAuthenticated.set(authenticated);
|
||||
onMount(() => {
|
||||
const supabase = getSupabase();
|
||||
|
||||
if (authenticated) {
|
||||
const user = getUser(kc);
|
||||
currentUser.set(user);
|
||||
if (kc.token) setSupabaseToken(kc.token);
|
||||
const {
|
||||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
currentUser.set(session?.user ?? null);
|
||||
authLoading.set(false);
|
||||
|
||||
// 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();
|
||||
if (!session && !isPublicRoute($page.url.pathname)) {
|
||||
// No session on a protected route → redirect to Keycloak
|
||||
await login();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (session && event === 'SIGNED_IN') {
|
||||
await loadUserCollectives(session.user.id);
|
||||
|
||||
// Post-login routing: onboarding if user has no collective
|
||||
if ($userCollectives.length === 0 && $page.url.pathname === '/') {
|
||||
goto('/onboarding');
|
||||
} else if ($page.url.pathname === '/') {
|
||||
goto('/lists');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
|
||||
async function loadUserCollectives(userId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
|
||||
const { data } = await supabase
|
||||
.from('collective_members')
|
||||
.select('collective_id, role, collectives(id, name, emoji, created_at)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
type CollectiveRow = { id: string; name: string; emoji: string; created_at: string };
|
||||
const collectives = data
|
||||
.map((row) => {
|
||||
const c = row.collectives as CollectiveRow | null;
|
||||
return c ? { id: c.id, name: c.name, emoji: c.emoji, created_at: c.created_at } : null;
|
||||
})
|
||||
.filter((c): c is CollectiveRow => c !== null);
|
||||
|
||||
userCollectives.set(collectives);
|
||||
|
||||
// Restore last active collective from localStorage, or default to first
|
||||
const savedId = localStorage.getItem('activeCollectiveId');
|
||||
const active = collectives.find((c) => c.id === savedId) ?? collectives[0] ?? null;
|
||||
currentCollective.set(active);
|
||||
if (active) localStorage.setItem('activeCollectiveId', active.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ParaglideJS {i18n}>
|
||||
|
||||
39
apps/web/src/routes/auth/callback/+page.svelte
Normal file
39
apps/web/src/routes/auth/callback/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const code = $page.url.searchParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
error = 'No authorization code received.';
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: exchangeError } = await getSupabase().auth.exchangeCodeForSession(code);
|
||||
|
||||
if (exchangeError) {
|
||||
error = exchangeError.message;
|
||||
return;
|
||||
}
|
||||
|
||||
// onAuthStateChange in root layout handles post-login routing
|
||||
goto('/');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen items-center justify-center bg-background">
|
||||
{#if error}
|
||||
<div class="max-w-sm text-center">
|
||||
<p class="mb-4 text-sm text-red-600">{error}</p>
|
||||
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
102
apps/web/src/routes/invitation/[token]/+page.svelte
Normal file
102
apps/web/src/routes/invitation/[token]/+page.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { isAuthenticated } from '$lib/stores/auth';
|
||||
import { login } from '$lib/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Status = 'loading' | 'ready' | 'accepting' | 'error' | 'success';
|
||||
|
||||
let status = $state<Status>('loading');
|
||||
let errorKey = $state<string>('');
|
||||
|
||||
const token = $page.params.token ?? '';
|
||||
|
||||
onMount(async () => {
|
||||
if (!token) {
|
||||
errorKey = 'not_found';
|
||||
status = 'error';
|
||||
return;
|
||||
}
|
||||
if (!$isAuthenticated) {
|
||||
// After login, GoTrue redirects to /auth/callback which goes to /.
|
||||
// We store the invitation token so we can resume after login.
|
||||
sessionStorage.setItem('pendingInvitationToken', token);
|
||||
await login(`${window.location.origin}/auth/callback`);
|
||||
return;
|
||||
}
|
||||
status = 'ready';
|
||||
});
|
||||
|
||||
async function accept() {
|
||||
status = 'accepting';
|
||||
|
||||
const { data, error } = await getSupabase().rpc('accept_invitation', { p_token: token });
|
||||
|
||||
if (error || !data) {
|
||||
errorKey = error?.message ?? 'unknown';
|
||||
status = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = data as { ok?: boolean; error?: string; collective_id?: string };
|
||||
|
||||
if (result.error) {
|
||||
errorKey = result.error;
|
||||
status = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
status = 'success';
|
||||
setTimeout(() => goto('/lists'), 1500);
|
||||
}
|
||||
|
||||
function errorMessage(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
not_found: m.invitation_error_not_found(),
|
||||
already_used: m.invitation_error_already_used(),
|
||||
expired: m.invitation_error_expired(),
|
||||
already_member: m.invitation_error_already_member(),
|
||||
unauthenticated: m.invitation_error_unauthenticated()
|
||||
};
|
||||
return map[key] ?? m.invitation_error_unknown();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-sm text-center">
|
||||
{#if status === 'loading'}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
|
||||
{:else if status === 'ready'}
|
||||
<div class="mb-6 text-5xl">🏠</div>
|
||||
<h1 class="mb-2 text-xl font-bold text-slate-900 dark:text-slate-50">
|
||||
{m.invitation_title()}
|
||||
</h1>
|
||||
<p class="mb-8 text-sm text-text-secondary">{m.invitation_subtitle()}</p>
|
||||
<button
|
||||
onclick={accept}
|
||||
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
|
||||
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{m.invitation_accept_btn()}
|
||||
</button>
|
||||
|
||||
{:else if status === 'accepting'}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
|
||||
{:else if status === 'success'}
|
||||
<div class="mb-4 text-4xl">✓</div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.invitation_success()}
|
||||
</p>
|
||||
|
||||
{:else if status === 'error'}
|
||||
<div class="mb-4 text-4xl">✕</div>
|
||||
<p class="mb-4 text-sm text-red-600">{errorMessage(errorKey)}</p>
|
||||
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,25 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Tab = 'create' | 'join';
|
||||
|
||||
let activeTab = $state<Tab>('create');
|
||||
|
||||
// Create collective form
|
||||
let collectiveName = $state('');
|
||||
let collectiveEmoji = $state('🏠');
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
||||
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
||||
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
||||
];
|
||||
|
||||
// Join via invite link
|
||||
let inviteLink = $state('');
|
||||
let joining = $state(false);
|
||||
let joinError = $state<string | null>(null);
|
||||
|
||||
async function createCollective() {
|
||||
if (!collectiveName.trim()) return;
|
||||
creating = true;
|
||||
createError = null;
|
||||
|
||||
const supabase = getSupabase();
|
||||
const userId = $currentUser?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const { data: collective, error: collectiveError } = await supabase
|
||||
.from('collectives')
|
||||
.insert({ name: collectiveName.trim(), emoji: collectiveEmoji, created_by: userId })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (collectiveError || !collective) {
|
||||
createError = collectiveError?.message ?? 'Failed to create collective.';
|
||||
creating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: memberError } = await supabase
|
||||
.from('collective_members')
|
||||
.insert({ collective_id: collective.id, user_id: userId, role: 'admin' });
|
||||
|
||||
if (memberError) {
|
||||
createError = memberError.message;
|
||||
creating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
userCollectives.update((list) => [...list, collective]);
|
||||
currentCollective.set(collective);
|
||||
localStorage.setItem('activeCollectiveId', collective.id);
|
||||
goto('/lists');
|
||||
}
|
||||
|
||||
async function joinViaLink() {
|
||||
joinError = null;
|
||||
const tokenMatch = inviteLink.trim().match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
if (!tokenMatch) {
|
||||
joinError = m.invitation_invalid_link();
|
||||
return;
|
||||
}
|
||||
goto(`/invitation/${tokenMatch[1]}`);
|
||||
}
|
||||
</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 class="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<h1 class="mb-2 text-2xl font-bold text-slate-900 dark:text-slate-50">
|
||||
{m.onboarding_title()}
|
||||
</h1>
|
||||
<p class="mb-8 text-sm text-text-secondary">{m.onboarding_subtitle()}</p>
|
||||
|
||||
<!-- Tab switcher -->
|
||||
<div class="mb-6 flex rounded-lg border border-slate-200 p-1 dark:border-slate-700">
|
||||
<button
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
{activeTab === 'create'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
|
||||
onclick={() => (activeTab = 'create')}
|
||||
>
|
||||
{m.onboarding_create_tab()}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
{activeTab === 'join'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
|
||||
onclick={() => (activeTab = 'join')}
|
||||
>
|
||||
{m.onboarding_join_tab()}
|
||||
</button>
|
||||
</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>
|
||||
{#if activeTab === 'create'}
|
||||
<form onsubmit={(e) => { e.preventDefault(); createCollective(); }} class="space-y-4">
|
||||
<div>
|
||||
<label for="collective-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_name()}
|
||||
</label>
|
||||
<input
|
||||
id="collective-name"
|
||||
type="text"
|
||||
bind:value={collectiveName}
|
||||
placeholder={m.onboarding_collective_name_placeholder()}
|
||||
maxlength="60"
|
||||
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_emoji()}
|
||||
</p>
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_OPTIONS as e}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{collectiveEmoji === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
onclick={() => (collectiveEmoji = e)}
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if createError}
|
||||
<p class="text-sm text-red-600">{createError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!collectiveName.trim() || creating}
|
||||
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
|
||||
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{creating ? m.loading() : m.onboarding_create_btn()}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); joinViaLink(); }} class="space-y-4">
|
||||
<div>
|
||||
<label for="invite-link" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_invite_link_label()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-link"
|
||||
type="text"
|
||||
bind:value={inviteLink}
|
||||
placeholder={m.onboarding_invite_link_placeholder()}
|
||||
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if joinError}
|
||||
<p class="text-sm text-red-600">{joinError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inviteLink.trim() || joining}
|
||||
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
|
||||
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{joining ? m.loading() : m.onboarding_join_btn()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user