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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="page">
|
||||
<div class="container">
|
||||
<h1>Tags</h1>
|
||||
<p class="text-secondary">Tags view coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user