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:
@@ -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>
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
<script>
|
<script>
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import '../app.css';
|
||||||
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||||
let { children } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<div class="app">
|
||||||
<link rel="icon" href={favicon} />
|
<main class="main">
|
||||||
</svelte:head>
|
<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