feat(frontend): add core UI components and pages

- Add global CSS with mobile-first styling and CSS custom properties
- Create base UI components: Button, Input, Checkbox, Select
- Add BottomNav component with icons for Tasks, Projects, Tags, Settings
- Update app layout to include BottomNav and auth handling
- Create Settings page with API key input and sync controls
- Create auth pages: /auth/login (OAuth) and /auth/callback
- Add placeholder pages for Projects and Tags
- Implement manual API key authentication for MVP testing
- Add logout functionality and user info display
- Support for safe-area-inset (mobile notches)
This commit is contained in:
2026-01-06 16:14:24 +01:00
parent d99e158a8c
commit 6b146c16a8
12 changed files with 1032 additions and 7 deletions
+28 -7
View File
@@ -1,11 +1,32 @@
<script>
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
import '../app.css';
import BottomNav from '$lib/components/BottomNav.svelte';
import { authStore } from '$lib/stores/auth.js';
import { page } from '$app/stores';
// Check if on auth pages (don't show nav)
$: isAuthPage = $page.url.pathname.startsWith('/auth');
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="app">
<main class="main">
<slot />
</main>
{#if $authStore.isAuthenticated && !isAuthPage}
<BottomNav />
{/if}
</div>
{@render children()}
<style>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main {
flex: 1;
width: 100%;
}
</style>
@@ -0,0 +1,83 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth } from '$lib/api/endpoints.js';
import { authStore } from '$lib/stores/auth.js';
let status = 'Processing...';
let error = '';
onMount(async () => {
const code = $page.url.searchParams.get('code');
if (!code) {
error = 'No authorization code received';
return;
}
try {
const data = await auth.callback(code);
authStore.setAuth(data);
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Authentication failed';
}
});
</script>
<div class="page callback-page">
<div class="container">
<div class="callback-card">
{#if error}
<h1>Authentication Failed</h1>
<p class="error-message">{error}</p>
<a href="/auth/login" class="btn-link">Try Again</a>
{:else}
<div class="loading-spinner">
<div class="loading"></div>
</div>
<p class="text-center text-secondary">{status}</p>
{/if}
</div>
</div>
</div>
<style>
.callback-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.callback-card {
text-align: center;
max-width: 400px;
}
.loading-spinner {
display: flex;
justify-content: center;
margin-bottom: var(--spacing-lg);
}
.loading {
width: 3rem;
height: 3rem;
}
.error-message {
color: var(--color-danger);
margin-bottom: var(--spacing-lg);
}
.btn-link {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
color: white;
border-radius: var(--border-radius);
text-decoration: none;
}
</style>
@@ -0,0 +1,79 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/api/endpoints.js';
import Button from '$lib/components/ui/Button.svelte';
let loading = false;
let error = '';
async function loginWithOAuth() {
loading = true;
error = '';
try {
const { url } = await auth.getLoginUrl();
window.location.href = url;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to get login URL';
loading = false;
}
}
</script>
<div class="page login-page">
<div class="container">
<div class="login-card">
<h1 class="text-center">Welcome to Opal</h1>
<p class="text-center text-secondary mb-lg">Mobile-first task management</p>
{#if error}
<div class="error-banner">
{error}
</div>
{/if}
<Button
on:click={loginWithOAuth}
loading={loading}
fullWidth
size="lg"
>
Login with OAuth
</Button>
<div class="mt-lg text-center">
<p class="text-sm text-secondary">
Or use <a href="/settings">API Key authentication</a>
</p>
</div>
</div>
</div>
</div>
<style>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-card {
background-color: var(--bg-primary);
border-radius: var(--border-radius);
padding: var(--spacing-xl);
box-shadow: var(--shadow-lg);
max-width: 400px;
width: 100%;
}
.error-banner {
background-color: #fee2e2;
color: var(--color-danger);
padding: var(--spacing-md);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
</style>
@@ -0,0 +1,6 @@
<div class="page">
<div class="container">
<h1>Projects</h1>
<p class="text-secondary">Projects view coming soon...</p>
</div>
</div>
+192
View File
@@ -0,0 +1,192 @@
<script>
import { authStore } from '$lib/stores/auth.js';
import { syncStore } from '$lib/stores/sync.js';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import { auth } from '$lib/api/endpoints.js';
import { goto } from '$app/navigation';
let apiKey = '';
let saving = false;
let error = '';
/**
* Save API key as manual auth
*/
async function saveApiKey() {
if (!apiKey.trim()) {
error = 'API key is required';
return;
}
saving = true;
error = '';
try {
// Store API key as access token (for manual auth mode)
authStore.setAuth({
access_token: apiKey,
refresh_token: '',
expires_at: 9999999999, // Far future
token_type: 'Bearer',
user: {
id: 1,
username: 'api-user',
email: null
}
});
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save API key';
} finally {
saving = false;
}
}
/**
* Logout
*/
async function logout() {
if ($authStore.refreshToken) {
try {
await auth.logout($authStore.refreshToken);
} catch (err) {
console.error('Logout error:', err instanceof Error ? err.message : err);
}
}
authStore.clear();
goto('/auth/login');
}
/**
* Trigger manual sync
*/
async function triggerSync() {
try {
await syncStore.sync();
alert('Sync completed successfully');
} catch (err) {
alert('Sync failed: ' + (err instanceof Error ? err.message : String(err)));
}
}
</script>
<div class="page">
<div class="container">
<h1>Settings</h1>
{#if $authStore.isAuthenticated}
<section class="section">
<h2>Account</h2>
<div class="info-row">
<span class="label">Username:</span>
<span class="value">{$authStore.user?.username || 'Unknown'}</span>
</div>
{#if $authStore.user?.email}
<div class="info-row">
<span class="label">Email:</span>
<span class="value">{$authStore.user.email}</span>
</div>
{/if}
</section>
<section class="section">
<h2>Sync</h2>
<div class="info-row">
<span class="label">Status:</span>
<span class="value">{$syncStore.status}</span>
</div>
<div class="info-row">
<span class="label">Queue:</span>
<span class="value">{$syncStore.queueSize} changes</span>
</div>
{#if $syncStore.lastSync}
<div class="info-row">
<span class="label">Last Sync:</span>
<span class="value">{new Date($syncStore.lastSync * 1000).toLocaleString()}</span>
</div>
{/if}
<Button on:click={triggerSync} disabled={$syncStore.status === 'syncing'}>
{$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'}
</Button>
</section>
<section class="section">
<h2>Actions</h2>
<Button variant="danger" on:click={logout}>Logout</Button>
</section>
{:else}
<section class="section">
<h2>API Key Authentication</h2>
<p class="text-secondary mb-md">
For testing, you can authenticate with an API key. Generate a key using:
<code>opal server keygen --name "Web"</code>
</p>
<Input
label="API Key"
type="password"
placeholder="oak_..."
bind:value={apiKey}
{error}
/>
<Button
on:click={saveApiKey}
loading={saving}
fullWidth
>
Save API Key
</Button>
<div class="mt-lg text-center">
<p class="text-sm text-secondary">
Or <a href="/auth/login">login with OAuth</a>
</p>
</div>
</section>
{/if}
</div>
</div>
<style>
.section {
background-color: var(--bg-primary);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-sm);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-color);
}
.info-row:last-of-type {
border-bottom: none;
}
.label {
font-weight: 500;
color: var(--text-secondary);
}
.value {
color: var(--text-primary);
}
code {
background-color: var(--bg-tertiary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: var(--font-size-sm);
}
</style>
+6
View File
@@ -0,0 +1,6 @@
<div class="page">
<div class="container">
<h1>Tags</h1>
<p class="text-secondary">Tags view coming soon...</p>
</div>
</div>