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
+186
View File
@@ -0,0 +1,186 @@
/* Global Styles - Mobile-First */
:root {
/* Colors */
--color-primary: #4f46e5;
--color-primary-dark: #4338ca;
--color-secondary: #6b7280;
--color-success: #10b981;
--color-danger: #ef4444;
--color-warning: #f59e0b;
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
/* Text */
--text-primary: #111827;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* Borders */
--border-color: #e5e7eb;
--border-radius: 0.5rem;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
/* Layout */
--nav-height: 60px;
--content-max-width: 768px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* Typography */
h1 {
font-size: var(--font-size-2xl);
font-weight: 700;
line-height: 1.2;
margin-bottom: var(--spacing-md);
}
h2 {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: 1.3;
margin-bottom: var(--spacing-sm);
}
h3 {
font-size: var(--font-size-lg);
font-weight: 600;
line-height: 1.4;
margin-bottom: var(--spacing-sm);
}
p {
margin-bottom: var(--spacing-md);
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Layout Helpers */
.container {
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.page {
min-height: calc(100vh - var(--nav-height));
padding-bottom: calc(var(--nav-height) + var(--spacing-md));
}
/* Utility Classes */
.text-center {
text-align: center;
}
.text-sm {
font-size: var(--font-size-sm);
}
.text-secondary {
color: var(--text-secondary);
}
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mt-lg { margin-top: var(--spacing-lg); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.hidden {
display: none;
}
/* Loading State */
.loading {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid var(--color-primary);
border-radius: 50%;
border-top-color: transparent;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Touch Targets - Mobile First */
button,
a,
input,
select,
textarea {
min-height: 44px;
min-width: 44px;
}
/* Focus Styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Smooth Scrolling */
html {
scroll-behavior: smooth;
}
/* Safe Area Padding (for mobile notches) */
@supports (padding: env(safe-area-inset-bottom)) {
.page {
padding-bottom: calc(var(--nav-height) + var(--spacing-md) + env(safe-area-inset-bottom));
}
}
@@ -0,0 +1,91 @@
<script>
import { page } from '$app/stores';
/**
* @param {string} path
* @returns {boolean}
*/
function isActive(path) {
return $page.url.pathname === path || $page.url.pathname.startsWith(path + '/');
}
</script>
<nav class="bottom-nav">
<a href="/" class="nav-item" class:active={$page.url.pathname === '/'}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span class="label">Tasks</span>
</a>
<a href="/projects" class="nav-item" class:active={isActive('/projects')}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="label">Projects</span>
</a>
<a href="/tags" class="nav-item" class:active={isActive('/tags')}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="label">Tags</span>
</a>
<a href="/settings" class="nav-item" class:active={isActive('/settings')}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="label">Settings</span>
</a>
</nav>
<style>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--nav-height);
background-color: var(--bg-primary);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
flex: 1;
height: 100%;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.nav-item:hover {
text-decoration: none;
}
.nav-item.active {
color: var(--color-primary);
}
.icon {
width: 1.5rem;
height: 1.5rem;
}
.label {
font-size: var(--font-size-xs);
font-weight: 500;
}
</style>
@@ -0,0 +1,115 @@
<script>
/**
* @type {'primary' | 'secondary' | 'danger' | 'ghost'}
*/
export let variant = 'primary';
/**
* @type {'sm' | 'md' | 'lg'}
*/
export let size = 'md';
/**
* @type {'button' | 'submit' | 'reset'}
*/
export let type = 'button';
export let disabled = false;
export let loading = false;
export let fullWidth = false;
</script>
<button
class="btn btn-{variant} btn-{size}"
class:btn-full={fullWidth}
class:btn-loading={loading}
{type}
{disabled}
on:click
>
{#if loading}
<span class="loading"></span>
{/if}
<slot />
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 500;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: var(--font-size-sm);
}
.btn-md {
padding: 0.5rem 1rem;
font-size: var(--font-size-base);
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-lg);
}
/* Variants */
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-primary-dark);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--border-color);
}
.btn-danger {
background-color: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
opacity: 0.9;
}
.btn-ghost {
background-color: transparent;
color: var(--text-primary);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--bg-tertiary);
}
.btn-full {
width: 100%;
}
.btn-loading {
pointer-events: none;
}
</style>
@@ -0,0 +1,86 @@
<script>
export let checked = false;
export let label = '';
export let disabled = false;
export let id = '';
</script>
<label class="checkbox-container" class:disabled>
<input
{id}
type="checkbox"
bind:checked
{disabled}
class="checkbox"
on:change
/>
<span class="checkmark"></span>
{#if label}
<span class="label">{label}</span>
{/if}
<slot />
</label>
<style>
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
position: relative;
padding-left: 1.75rem;
min-height: 44px;
}
.checkbox-container.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
left: 0;
height: 1.25rem;
width: 1.25rem;
background-color: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 0.25rem;
transition: all 0.2s;
}
.checkbox:checked ~ .checkmark {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.checkmark:after {
content: "";
position: absolute;
display: none;
left: 0.375rem;
top: 0.125rem;
width: 0.375rem;
height: 0.625rem;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox:checked ~ .checkmark:after {
display: block;
}
.label {
font-size: var(--font-size-base);
color: var(--text-primary);
}
</style>
@@ -0,0 +1,87 @@
<script>
export let type = 'text';
export let value = '';
export let placeholder = '';
export let label = '';
export let error = '';
export let disabled = false;
export let required = false;
export let id = '';
</script>
<div class="input-group">
{#if label}
<label for={id} class="label">
{label}
{#if required}<span class="required">*</span>{/if}
</label>
{/if}
<input
{id}
{type}
{placeholder}
{disabled}
{required}
bind:value
class="input"
class:input-error={error}
on:input
on:change
on:blur
/>
{#if error}
<span class="error-message">{error}</span>
{/if}
</div>
<style>
.input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.required {
color: var(--color-danger);
}
.input {
width: 100%;
padding: 0.75rem;
font-size: var(--font-size-base);
font-family: inherit;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input:disabled {
background-color: var(--bg-tertiary);
cursor: not-allowed;
}
.input-error {
border-color: var(--color-danger);
}
.error-message {
font-size: var(--font-size-sm);
color: var(--color-danger);
}
</style>
@@ -0,0 +1,73 @@
<script>
/**
* @type {Array<{value: string, label: string}>}
*/
export let options = [];
export let value = '';
export let label = '';
export let placeholder = 'Select...';
export let disabled = false;
export let id = '';
</script>
<div class="select-group">
{#if label}
<label for={id} class="label">{label}</label>
{/if}
<select
{id}
bind:value
{disabled}
class="select"
on:change
>
<option value="" disabled selected>{placeholder}</option>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<style>
.select-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.select {
width: 100%;
padding: 0.75rem;
font-size: var(--font-size-base);
font-family: inherit;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1.5rem;
padding-right: 2.5rem;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
}
.select:disabled {
background-color: var(--bg-tertiary);
cursor: not-allowed;
}
</style>
+27 -6
View File
@@ -1,11 +1,32 @@
<script>
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import BottomNav from '$lib/components/BottomNav.svelte';
import { authStore } from '$lib/stores/auth.js';
import { page } from '$app/stores';
let { children } = $props();
// 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>
{@render children()}
{#if $authStore.isAuthenticated && !isAuthPage}
<BottomNav />
{/if}
</div>
<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>