You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

629 lines
15 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script>
import { onMount, onDestroy } from 'svelte';
import { push, location } from 'svelte-spa-router';
import { auth } from './stores/auth.js';
import { navContext } from './stores/navContext.js';
import { getProfile } from './api/profile.js';
$: loc = $location || '';
$: myDecksActive = loc === '/' || loc === '/decks/new' || /^\/decks\/[^/]+\/edit$/.test(loc) || $navContext === 'my-decks';
$: communityActive = loc === '/community' || loc.startsWith('/community/') || $navContext === 'community';
let showPopup = false;
let mode = 'login'; // 'login' | 'register'
let email = '';
let username = '';
let password = '';
let submitting = false;
let registerSuccess = false;
let userMenuOpen = false;
let profile = null;
let profileLoading = false;
function openPopup() {
auth.clearError();
showPopup = true;
mode = 'login';
email = '';
username = '';
password = '';
registerSuccess = false;
}
function closePopup() {
showPopup = false;
email = '';
username = '';
password = '';
registerSuccess = false;
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) closePopup();
}
async function handleSubmit() {
if (!email.trim() || !password) return;
submitting = true;
registerSuccess = false;
if (mode === 'login') {
const ok = await auth.login(email.trim(), password);
if (ok) closePopup();
} else {
const result = await auth.register(email.trim(), password, username.trim());
if (result?.success) {
if (result.needsConfirmation) {
registerSuccess = true;
} else {
closePopup();
}
}
}
submitting = false;
}
function handleLogout() {
userMenuOpen = false;
auth.logout();
push('/');
}
function goSettings(e) {
if (e) e.preventDefault();
userMenuOpen = false;
push('/settings');
}
function toggleUserMenu() {
userMenuOpen = !userMenuOpen;
if (userMenuOpen && $auth.user?.id && !profileLoading) {
profileLoading = true;
getProfile($auth.user.id).then((p) => {
profile = p;
profileLoading = false;
}).catch(() => { profileLoading = false; });
}
}
function handleClickOutside(e) {
if (userMenuOpen && !e.target.closest('.user-menu-wrap')) userMenuOpen = false;
}
$: userId = $auth.user?.id;
$: if (!$auth.user) {
profile = null;
userMenuOpen = false;
}
/** Load profile (including avatar) when user is logged in, so avatar shows after refresh */
$: if (!$auth.loading && $auth.user?.id && !profile && !profileLoading) {
profileLoading = true;
getProfile($auth.user.id)
.then((p) => {
profile = p;
profileLoading = false;
})
.catch(() => {
profileLoading = false;
});
}
function onProfileUpdated(e) {
const p = e?.detail;
if (p && p.id && $auth.user?.id && p.id === $auth.user.id) {
profile = p;
}
}
onMount(() => {
window.addEventListener('profile-updated', onProfileUpdated);
});
onDestroy(() => {
window.removeEventListener('profile-updated', onProfileUpdated);
});
</script>
<svelte:window on:click={handleClickOutside} />
<nav class="navbar">
<div class="nav-left">
<a href="/" class="app-brand" onclick={(e) => { e.preventDefault(); push('/'); }}>
<img src="/omotomo.png" alt="Omotomo" class="app-logo" width="36" height="36" />
<span class="app-name">Omotomo</span>
</a>
{#if $auth.user}
<a href="/" class="nav-link" class:active={myDecksActive} onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a>
{/if}
<a href="/community" class="nav-link" class:active={communityActive} onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a>
</div>
<div class="nav-actions">
{#if $auth.user}
<button type="button" class="btn btn-primary" onclick={() => push('/decks/new')}>Create deck</button>
{/if}
{#if $auth.loading}
<span class="username"></span>
{:else if $auth.user}
<div class="user-menu-wrap">
<button
type="button"
class="user-menu-trigger"
onclick={toggleUserMenu}
aria-haspopup="true"
aria-expanded={userMenuOpen}
aria-label="User menu"
>
{#if profile?.avatar_url}
<img src={profile.avatar_url} alt="" class="user-avatar" width="32" height="32" />
{:else}
<span class="user-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
{/if}
</button>
{#if userMenuOpen}
<div class="user-dropdown" role="menu">
{#if profile}
<div class="user-dropdown-header">
<span class="user-dropdown-name">{profile.display_name || profile.email || 'User'}</span>
<span class="user-dropdown-email">{profile.email}</span>
</div>
{/if}
<a href="/settings" class="user-dropdown-item" role="menuitem" onclick={goSettings}>
Settings
</a>
<button type="button" class="user-dropdown-item user-dropdown-item-logout" role="menuitem" onclick={handleLogout}>
Logout
</button>
</div>
{/if}
</div>
{:else}
<button type="button" class="btn btn-login" onclick={openPopup}>Login</button>
{/if}
</div>
</nav>
{#if showPopup}
<div class="popup-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="popup" role="dialog" aria-modal="true" aria-labelledby="auth-popup-title">
<div class="popup-header">
<h2 id="auth-popup-title" class="popup-title">{mode === 'login' ? 'Sign in' : 'Register'}</h2>
<button type="button" class="popup-close" onclick={closePopup} aria-label="Close">×</button>
</div>
<div class="popup-tabs">
<button
type="button"
class="tab"
class:active={mode === 'login'}
onclick={() => { mode = 'login'; auth.clearError(); registerSuccess = false; }}
>
Log in
</button>
<button
type="button"
class="tab"
class:active={mode === 'register'}
onclick={() => { mode = 'register'; auth.clearError(); registerSuccess = false; }}
>
Register
</button>
</div>
{#if registerSuccess && !$auth.user}
<p class="auth-message auth-success">
Check your email to confirm your account.
</p>
{:else}
<form class="popup-form" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<input
type={mode === 'login' ? 'text' : 'email'}
bind:value={email}
placeholder={mode === 'login' ? 'Email or username' : 'Email'}
class="input"
disabled={submitting}
autocomplete={mode === 'login' ? 'username' : 'email'}
/>
{#if mode === 'register'}
<input
type="text"
bind:value={username}
placeholder="Username"
class="input"
disabled={submitting}
autocomplete="username"
/>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password"
class="input"
disabled={submitting}
autocomplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
{#if $auth.error}
<p class="auth-error">{$auth.error}</p>
{#if mode === 'login'}
<p class="auth-switch">
Do you have an account? <button type="button" class="auth-link" onclick={() => { mode = 'register'; auth.clearError(); }}>Register here</button>.
</p>
{/if}
{/if}
<button
type="submit"
class="btn btn-primary btn-block"
disabled={submitting || !email.trim() || !password}
>
{#if submitting}
{mode === 'login' ? 'Logging in…' : 'Creating account…'}
{:else}
{mode === 'login' ? 'Log in' : 'Create account'}
{/if}
</button>
</form>
{/if}
</div>
</div>
{/if}
<style>
.navbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1.5rem;
background: var(--nav-bg, #161616);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
.nav-left {
display: flex;
align-items: center;
gap: 1.25rem;
}
.app-brand {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.app-logo {
display: block;
width: 36px;
height: 36px;
object-fit: contain;
border-radius: 6px;
}
.app-name {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
text-decoration: none;
}
.app-brand:hover .app-name {
color: var(--text-primary, #f0f0f0);
}
.nav-link {
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
text-decoration: none;
}
.nav-link:hover {
color: var(--text-primary, #f0f0f0);
}
.nav-link.active {
color: var(--text-primary, #f0f0f0);
font-weight: 500;
}
.nav-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.username {
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
}
.user-menu-wrap {
position: relative;
}
.user-menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--card-bg, #1e1e1e);
color: var(--text-muted, #a0a0a0);
cursor: pointer;
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
}
.user-menu-trigger:hover {
color: var(--text-primary, #f0f0f0);
background: var(--hover-bg, #2a2a2a);
}
.user-menu-trigger:focus {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 2px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.user-icon {
display: flex;
align-items: center;
justify-content: center;
}
.user-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 200px;
padding: 0.5rem 0;
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 200;
}
.user-dropdown-header {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border, #333);
margin-bottom: 0.25rem;
}
.user-dropdown-name {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary, #f0f0f0);
}
.user-dropdown-email {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #a0a0a0);
}
.user-dropdown-item {
display: block;
width: 100%;
padding: 0.5rem 1rem;
font-size: 0.9rem;
text-align: left;
border: none;
background: none;
color: var(--text-primary, #f0f0f0);
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.user-dropdown-item:hover {
background: var(--hover-bg, #2a2a2a);
}
.user-dropdown-item-logout {
color: var(--text-muted, #a0a0a0);
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--card-bg, #1e1e1e);
color: var(--text-primary, #f0f0f0);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.btn:hover:not(:disabled) {
background: var(--hover-bg, #2a2a2a);
border-color: var(--border-hover, #444);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-login,
.btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
}
.btn-login:hover:not(:disabled),
.btn-primary:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
}
.btn-block {
width: 100%;
}
/* Popup */
.popup-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.popup {
width: 100%;
max-width: 380px;
padding: 1.5rem;
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.popup-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.popup-close {
width: 2rem;
height: 2rem;
padding: 0;
font-size: 1.5rem;
line-height: 1;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.popup-close:hover {
color: var(--text-primary, #f0f0f0);
background: var(--hover-bg, #222);
}
.popup-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
padding: 0.25rem;
background: var(--bg-muted, #252525);
border-radius: 8px;
}
.tab {
flex: 1;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.tab:hover {
color: var(--text-primary, #f0f0f0);
}
.tab.active {
background: var(--card-bg, #1a1a1a);
color: var(--text-primary, #f0f0f0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.popup-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.input {
width: 100%;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--bg, #0f0f0f);
color: var(--text-primary, #f0f0f0);
}
.input::placeholder {
color: var(--text-muted, #a0a0a0);
}
.input:focus {
outline: none;
border-color: var(--accent, #3b82f6);
}
.auth-error {
margin: 0;
font-size: 0.85rem;
color: #ef4444;
}
.auth-switch {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
}
.auth-link {
padding: 0;
font-size: inherit;
font-weight: 500;
border: none;
background: none;
color: var(--accent, #3b82f6);
cursor: pointer;
text-decoration: underline;
}
.auth-link:hover {
color: #60a5fa;
}
.auth-message {
margin: 0;
font-size: 0.9rem;
}
.auth-success {
color: #22c55e;
}
</style>

Powered by TurnKey Linux.