phase-10.5: market screener ui enhancements
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { page, navigating } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import '../styles/app.scss';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
@@ -9,6 +11,14 @@
|
||||
// so the nav link highlights immediately on click, not after load completes.
|
||||
const activePath = $derived($navigating?.to?.url?.pathname ?? $page.url.pathname);
|
||||
|
||||
// All routes except /auth/* require login
|
||||
$effect(() => {
|
||||
const path = $page.url.pathname;
|
||||
if (!path.startsWith('/auth/') && !authStore.isLoggedIn) {
|
||||
goto('/auth/login');
|
||||
}
|
||||
});
|
||||
|
||||
const navLabel = $derived(
|
||||
activePath === '/portfolio' ? 'Loading portfolio…' :
|
||||
activePath?.startsWith('/calls') ? 'Loading market calls…' :
|
||||
@@ -21,10 +31,22 @@
|
||||
<nav>
|
||||
<span class="brand">📊 Market Screener</span>
|
||||
<div class="links">
|
||||
<a href="/" class:active={activePath === '/'}>Screener</a>
|
||||
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
|
||||
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a>
|
||||
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</a>
|
||||
{#if authStore.isLoggedIn}
|
||||
<a href="/" class:active={activePath === '/'}>Screener</a>
|
||||
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
|
||||
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a>
|
||||
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="nav-auth">
|
||||
{#if authStore.isLoggedIn}
|
||||
<span class="nav-user">{authStore.user?.email}</span>
|
||||
<button class="btn-ghost btn-sm" onclick={() => { authStore.clearAuth(); goto('/auth/login'); }}>
|
||||
Sign out
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/auth/login" class="btn-ghost btn-sm">Sign in</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import VerdictPill from '$lib/components/shared/VerdictPill.svelte';
|
||||
import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
||||
@@ -23,14 +21,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="screener-page">
|
||||
|
||||
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-top">
|
||||
<button onclick={() => s.reloadCatalysts()} disabled={s.loading || s.loadingCats} class="btn-catalyst">
|
||||
{#if s.loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => searchOpen = !searchOpen}
|
||||
class="btn-search-toggle"
|
||||
@@ -46,6 +41,7 @@
|
||||
{#if searchOpen}
|
||||
<div class="search-row">
|
||||
<input
|
||||
class="search-input"
|
||||
bind:value={s.input}
|
||||
placeholder="AAPL, MSFT, VOO …"
|
||||
onkeydown={e => e.key === 'Enter' && s.screen()}
|
||||
@@ -72,43 +68,6 @@
|
||||
{/if}
|
||||
|
||||
{#if s.results && !s.loading && !s.loadingCats}
|
||||
<!-- ── Signal Summary ───────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Signal Summary</h2>
|
||||
<span class="count">{s.allAssets.length} assets</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Type</th>
|
||||
<th>Signal</th>
|
||||
<th>Mkt-Adjusted</th>
|
||||
<th>Fundamental</th>
|
||||
<th title="Market cap tier (stocks only)">Cap</th>
|
||||
<th title="Growth / style classification (stocks only)">Style</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each s.allAssets as r}
|
||||
{@const dm = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td><span class="tag">{r.asset.type}</span></td>
|
||||
<td><SignalBadge signal={r.signal} /></td>
|
||||
<td><VerdictPill label={r.inflated.label} /></td>
|
||||
<td><VerdictPill label={r.fundamental.label} /></td>
|
||||
<td class="dim-cell">{dm['Cap Tier'] ?? '—'}</td>
|
||||
<td class="dim-cell">{dm['Style'] ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Per-type detail tables ────────────────────────────────────── -->
|
||||
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
|
||||
{#if s.results[type]?.length}
|
||||
@@ -136,50 +95,3 @@
|
||||
</div>
|
||||
|
||||
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
|
||||
|
||||
<style>
|
||||
.page { max-width: 1400px; padding-bottom: 60px; }
|
||||
|
||||
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.toolbar-top { display: flex; align-items: center; gap: 8px; }
|
||||
.search-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
padding: 10px var(--space-lg);
|
||||
font-size: var(--fs-md);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
|
||||
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f620; }
|
||||
}
|
||||
|
||||
.btn-search-toggle {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-input);
|
||||
font-size: 12px;
|
||||
padding: 8px var(--space-lg);
|
||||
|
||||
&:hover { background: #263347; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.screened-at {
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
|
||||
.dim-cell { font-size: var(--fs-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
|
||||
.error-list { padding: 12px var(--space-xl); display: flex; flex-direction: column; gap: 6px; }
|
||||
.error-item { color: var(--text-dim); font-size: 12px; }
|
||||
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
let email = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error: msg } = await res.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(msg);
|
||||
}
|
||||
success = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Forgot password</h1>
|
||||
<p class="login-subtitle">Enter your email and check the server console for a reset link.</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="success-banner">
|
||||
Reset link printed to server console. Copy it and open it in your browser.
|
||||
</div>
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/login">Back to sign in</a>
|
||||
</p>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span>Email</span>
|
||||
<input type="email" autocomplete="email" required bind:value={email} disabled={loading} />
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/login">Back to sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: color-mix(in srgb, var(--signal-buy) 15%, transparent);
|
||||
border: 1px solid var(--signal-buy);
|
||||
color: var(--signal-buy);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { login } from '$lib/api/auth.js';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
// If already logged in, redirect to home
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const { token, user } = await login(email, password);
|
||||
authStore.setAuth(token, user);
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Login failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Market Screener</h1>
|
||||
<p class="login-subtitle">Sign in to continue</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span>Email</span>
|
||||
<input
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
bind:value={email}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/forgot-password">Forgot password?</a>
|
||||
</p>
|
||||
<p class="auth-switch">
|
||||
Don't have an account? <a href="/auth/register">Register</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Auth page layout only — input styles come from global _forms.scss */
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { register } from '$lib/api/auth.js';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let inviteCode = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (password !== confirm) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const { token, user } = await register(email, password, 'trader', inviteCode);
|
||||
authStore.setAuth(token, user);
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Registration failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Create account</h1>
|
||||
<p class="login-subtitle">Market Screener</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span>Email</span>
|
||||
<input type="email" autocomplete="email" required bind:value={email} disabled={loading} />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Password <small>(min 8 characters)</small></span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={confirm}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Invite code</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ask the admin for an invite code"
|
||||
bind:value={inviteCode}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
Already have an account? <a href="/auth/login">Sign in</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Auth page layout only — input/select styles come from global _forms.scss */
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field small {
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const token = $derived($page.url.searchParams.get('token') ?? '');
|
||||
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (password !== confirm) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
error = 'Missing reset token — please use the full link from the console.';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error: msg } = await res.json().catch(() => ({ error: 'Reset failed' }));
|
||||
throw new Error(msg);
|
||||
}
|
||||
success = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Reset password</h1>
|
||||
<p class="login-subtitle">Choose a new password for your account.</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="success-banner">
|
||||
Password updated. You can now sign in with your new password.
|
||||
</div>
|
||||
<a href="/auth/login" class="btn-primary" style="text-align:center;">Go to sign in</a>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span>New password <small>(min 8 characters)</small></span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={confirm}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Updating…' : 'Set new password'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/login">Back to sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: color-mix(in srgb, var(--signal-buy) 15%, transparent);
|
||||
border: 1px solid var(--signal-buy);
|
||||
color: var(--signal-buy);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.field small {
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@
|
||||
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="safe-buys-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>🛡 Safe Buys</h1>
|
||||
@@ -231,60 +231,3 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ── unique to this route ──────────────────────────────── */
|
||||
.page { max-width: 1100px; padding-bottom: 60px; }
|
||||
|
||||
.page-header { margin-bottom: 20px; }
|
||||
h1 { font-size: var(--fs-2xl); font-weight: 700; color: var(--text-primary); margin-bottom: 6px; }
|
||||
.subtitle { font-size: 12px; color: var(--text-dimmer); line-height: 1.5; }
|
||||
.subtitle strong { color: var(--text-muted); }
|
||||
|
||||
/* ── Strong Buy banner ───────────────────────────────────────────── */
|
||||
.strong-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
|
||||
.strong-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
background: var(--green-bg);
|
||||
padding: 4px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.strong-sub { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
|
||||
.empty-strong {
|
||||
padding: var(--space-3xl) 20px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Watch List ──────────────────────────────────────────────────── */
|
||||
.watch-header { display: flex; align-items: center; gap: 12px; margin-top: 28px; margin-bottom: 12px; }
|
||||
|
||||
.watch-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
padding: 4px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.watch-sub { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
|
||||
/* Watch sections are slightly dimmed — hover to focus */
|
||||
.watch-section { opacity: 0.75; }
|
||||
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
|
||||
|
||||
/* ── Score cell ─────────────────────────────────────────────────── */
|
||||
.score { color: var(--text-dimmer); font-size: var(--fs-sm); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user