finish early dashboard prototype

This commit is contained in:
ari melody 2026-02-23 18:57:57 +00:00
parent 14535219d9
commit 7d15d70432
Signed by: ari
GPG key ID: CF99829C92678188
13 changed files with 311 additions and 73 deletions

View file

@ -60,3 +60,12 @@ hr {
border: none; border: none;
border-bottom: 1px solid var(--bg-2); border-bottom: 1px solid var(--bg-2);
} }
a {
color: var(--on-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
let { onclick, onkeydown } = $props();
</script>
<div class="add-account" onclick={onclick} onkeydown={onkeydown} role="button" tabindex="0">
<div class="icon-container"><Plus /></div>
<hr>
<div class="info-container"><h2>Add Account</h2></div>
</div>
<style>
.add-account {
width: 480px;
height: var(--sp-xxxl);
padding: .5em;
display: flex;
flex-direction: row;
background-color: color-mix(in srgb, var(--on-primary), var(--bg-1) 33%);
border: 1px solid var(--on-primary);
border-radius: var(--radius-0);
box-shadow: 0 var(--sp-ssm) var(--sp-sm) #0001;
transition: background-color .1s, border-color .1s;
cursor: pointer;
user-select: none;
}
.add-account:hover {
background-color: color-mix(in srgb, var(--on-primary), var(--bg-1) 20%);
border-color: var(--on-primary);
}
.add-account:active:not(:has(.email:active)) {
background-color: var(--on-primary);
}
.icon-container {
width: var(--sp-xxxl);
min-width: var(--sp-xxxl);
height: var(--sp-xxxl);
display: flex;
justify-content: center;
align-items: center;
}
.icon-container :global(svg) {
--size: 60%;
width: var(--size);
height: var(--size);
}
.add-account hr {
margin: 0 .5em;
border: none;
border-left: 1px solid var(--on-primary);
}
.info-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.info-container h2 {
margin-left: .2em;
}
</style>

View file

@ -1,25 +1,37 @@
<script lang="ts"> <script lang="ts">
import { Account } from '@jupiter/lib/account'; import { type Account } from '@jupiter/lib/account';
import { User } from '@lucide/svelte'; import { pushToast, ToastType } from '@jupiter/lib/toasts';
import { Copy, User } from '@lucide/svelte';
let { let {
account, account,
} = $props<{ } = $props<{
account: Account, account: Account,
}>(); }>();
async function copyEmail() {
await navigator.clipboard.write([new ClipboardItem({
"text/plain": `${account.username}@${account.domain}`,
})]);
pushToast("Email copied to clipboard.", ToastType.SUCCESS);
}
</script> </script>
<div class="account"> <div class="account" role="button">
<div class="icon-container"><User /></div> <div class="icon-container"><User /></div>
<hr> <hr>
<div class="info-container"> <div class="info-container">
<h2 class="name">{account.username}</h2> <h2 class="name">{account.username}</h2>
<p class="email">{account.username}@{account.domain}</p> <button class="email" onclick={() => {copyEmail()}}>
{account.username}@{account.domain}<Copy />
</button>
</div> </div>
</div> </div>
<style> <style>
.account { .account {
width: 480px;
height: var(--sp-xxxl); height: var(--sp-xxxl);
padding: .5em; padding: .5em;
@ -30,6 +42,8 @@
border: 1px solid var(--bg-2); border: 1px solid var(--bg-2);
border-radius: var(--radius-0); border-radius: var(--radius-0);
box-shadow: 0 var(--sp-ssm) var(--sp-sm) #0001;
transition: background-color .1s, border-color .1s; transition: background-color .1s, border-color .1s;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@ -97,6 +111,10 @@
font-size: 1.2em; font-size: 1.2em;
line-height: 1.6em; line-height: 1.6em;
display: flex;
flex-direction: row;
align-items: center;
color: var(--on-bg-3); color: var(--on-bg-3);
background-color: var(--bg-1); background-color: var(--bg-1);
border: 1px solid var(--bg-2); border: 1px solid var(--bg-2);
@ -114,4 +132,9 @@
.email:active { .email:active {
background-color: var(--bg-3); background-color: var(--bg-3);
} }
.info-container .email :global(svg) {
height: 1em;
margin-left: var(--sp-sm);
}
</style> </style>

View file

@ -0,0 +1,19 @@
<div class="card"><slot></slot></div>
<style>
.card {
width: 480px;
min-height: var(--sp-xxxl);
padding: 1em;
display: flex;
flex-direction: column;
background-color: var(--bg-0);
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
box-shadow: 0 var(--sp-ssm) var(--sp-sm) #0001;
user-select: none;
}
</style>

View file

@ -0,0 +1,45 @@
<script lang="ts">
let {
children,
href,
} = $props<{
children: () => any,
href: string
}>();
</script>
<a href="{href}">{@render children()}</a>
<style>
a {
margin-left: var(--sp-sm);
padding: .5em;
display: flex;
flex-direction: row;
justify-content: start;
align-items: center;
gap: .5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: var(--on-bg-2);
background-color: var(--bg-0);
text-decoration: none;
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
box-shadow: calc(0px - var(--sp-sm)) 0 0 var(--on-primary);
user-select: none;
}
a:hover {
background-color: var(--bg-1);
}
a:active {
background-color: var(--bg-2);
}
</style>

View file

@ -0,0 +1,37 @@
<div class="skeleton"><slot></slot></div>
<style>
:root {
--width: 500px;
}
.skeleton {
border: 1px solid var(--bg-2);
border-radius: var(--sp-sm);
background-image: linear-gradient(
90deg,
var(--bg-1),
var(--bg-0),
var(--bg-1)
);
background-size: var(--width) 100%;
background-repeat: repeat;
animation: skeleton 1s ease-in-out infinite;
}
@keyframes throb {
from { opacity: 1 }
to { opacity: .25 }
}
@keyframes skeleton {
from {
background-position: 0 0;
}
to {
background-position: var(--width) 0;
}
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { X } from '@lucide/svelte'; import { X } from '@lucide/svelte';
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { fade, fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { ToastTypeToString } from '@jupiter/lib/toasts'; import { ToastTypeToString } from '@jupiter/lib/toasts';
let { let {
@ -19,7 +19,7 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
<div class={["notification", ToastTypeToString(type)]} in:fly={{ x: 100 }} out:fade> <div class={["notification", ToastTypeToString(type)]} in:fly={{ x: 100 }}>
<span class="content">{text}</span> <span class="content">{text}</span>
{#if sticky} {#if sticky}
<div class="close"><X onclick={() => {dispatch("dismiss")}} /></div> <div class="close"><X onclick={() => {dispatch("dismiss")}} /></div>

View file

@ -1,5 +1,9 @@
export interface Account { import { writable } from "svelte/store";
export default interface Account {
username: string, username: string,
domain: string, domain: string,
mail_directory: string, mail_directory: string,
} }
export let accounts = writable<Account[] | null>(null);

View file

@ -19,25 +19,21 @@ export const DEFAULT_NOTIFICATION_TIMEOUT = 5000;
export const toasts = writable<Toast[]>([]); export const toasts = writable<Toast[]>([]);
export function pushToast(toast: Record<string, any>) { export function pushToast(
text: string,
type: ToastType,
sticky: boolean = false,
timeout: number = DEFAULT_NOTIFICATION_TIMEOUT,
) {
const id = Math.floor(Math.random() * 1_000_000); const id = Math.floor(Math.random() * 1_000_000);
const defaults: Toast = { let toast: Toast = { id, text, type, sticky, timeout };
id,
text: "",
type: ToastType.INFO,
sticky: false,
timeout: DEFAULT_NOTIFICATION_TIMEOUT,
};
toast = { ...defaults, ...toast }; toasts.update((toasts) => [...toasts, toast]);
console.log(`adding toast ${toast.id} (timeout ${toast.timeout})...`);
toasts.update((toasts) => [...toasts, { ...defaults, ...toast }]);
if (!toast.sticky) setTimeout(() => { dismissToast(id) }, toast.timeout); if (!toast.sticky) setTimeout(() => { dismissToast(id) }, toast.timeout);
} }
export function dismissToast(id: number) { export function dismissToast(id: number) {
console.log(`dismissing toast ${id}...`);
toasts.update((toasts) => toasts.filter((toast) => toast.id !== id)); toasts.update((toasts) => toasts.filter((toast) => toast.id !== id));
} }

View file

@ -4,6 +4,7 @@
import { LayoutDashboard, Mail, Settings, User } from '@lucide/svelte'; import { LayoutDashboard, Mail, Settings, User } from '@lucide/svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import ToastOverlay from '@jupiter/components/ui/ToastOverlay.svelte'; import ToastOverlay from '@jupiter/components/ui/ToastOverlay.svelte';
import SidebarLink from '@jupiter/components/ui/SidebarLink.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
@ -15,9 +16,9 @@
<div class="app"> <div class="app">
<header class="sidebar"> <header class="sidebar">
<h1><Mail /> Jupiter Mail</h1> <h1><Mail /> Jupiter Mail</h1>
<a href="/"><LayoutDashboard /> Dashboard</a> <SidebarLink href="/"><LayoutDashboard /> Dashboard</SidebarLink>
<a href="/accounts"><User /> Accounts</a> <SidebarLink href="/accounts"><User /> Accounts</SidebarLink>
<a href="/settings"><Settings /> Settings</a> <SidebarLink href="/settings"><Settings /> Settings</SidebarLink>
</header> </header>
<main> <main>
{@render children()} {@render children()}
@ -60,36 +61,4 @@
user-select: none; user-select: none;
} }
.sidebar a {
margin-left: var(--sp-sm);
padding: .5em;
display: flex;
flex-direction: row;
justify-content: start;
align-items: center;
gap: .5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: var(--on-primary);
background-color: var(--bg-0);
text-decoration: none;
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
box-shadow: calc(0px - var(--sp-sm)) 0 0 var(--on-primary);
user-select: none;
}
.sidebar a:hover {
background-color: var(--bg-1);
}
.sidebar a:active {
background-color: var(--bg-2);
}
</style> </style>

View file

@ -1,6 +1,16 @@
<script lang="ts"> <script lang="ts">
import { LayoutDashboard } from '@lucide/svelte'; import { onMount } from 'svelte';
import { LayoutDashboard, User } from '@lucide/svelte';
import Card from '@jupiter/components/ui/Card.svelte';
import Header from '@jupiter/components/ui/Header.svelte'; import Header from '@jupiter/components/ui/Header.svelte';
import * as api from '@jupiter/lib/api';
import { type Account, accounts } from '@jupiter/lib/account';
onMount(async () => {
if ($accounts == null) api.fetchAccounts().then(accs => {
accounts.update(() => accs);
});
});
</script> </script>
<div class="page-container"> <div class="page-container">
@ -8,6 +18,13 @@
<LayoutDashboard /> <LayoutDashboard />
<h1>Dashboard</h1> <h1>Dashboard</h1>
</Header> </Header>
<main>
<Card>
<h2><a href="/accounts">Accounts</a></h2>
<p class="active-accounts">Active: {$accounts.length}</p>
</Card>
</main>
</div> </div>
<style> <style>
@ -16,4 +33,26 @@
background-color: var(--bg-0); background-color: var(--bg-0);
} }
main {
margin: 0;
padding: 2em;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1em;
}
h1, h2, p {
margin: 0;
}
.active-accounts {
margin-top: .5em;
}
h2 a {
color: inherit;
}
</style> </style>

View file

@ -1,19 +1,23 @@
<script lang="ts"> <script lang="ts">
import { User } from '@lucide/svelte'; import { User } from '@lucide/svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import * as api from '@jupiter/lib/api';
import { type Account, accounts } from '@jupiter/lib/account';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import Header from '@jupiter/components/ui/Header.svelte'; import Header from '@jupiter/components/ui/Header.svelte';
import AccountListItem from '@jupiter/components/ui/AccountListItem.svelte'; import AccountListItem from '@jupiter/components/ui/AccountListItem.svelte';
import { type Account } from '@jupiter/lib/account'; import AccountAddButton from '@jupiter/components/ui/AccountAddButton.svelte';
import * as api from '@jupiter/lib/api'; import Skeleton from '@jupiter/components/ui/Skeleton.svelte';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
let accounts: Account[] = $state([]);
onMount(async () => { onMount(async () => {
pushToast({ text: "Fetching accounts...", type: ToastType.INFO }); if ($accounts == null) api.fetchAccounts().then(accs => {
accounts = await api.fetchAccounts(); accounts.update(() => accs);
pushToast({ text: "Accounts loaded successfully.", type: ToastType.SUCCESS });
}); });
});
function addAccount() {
alert("TODO: this");
}
</script> </script>
<div class="page-container"> <div class="page-container">
@ -26,9 +30,21 @@
<p>Click an account below to configure:</p> <p>Click an account below to configure:</p>
<hr> <hr>
<div class="account-list"> <div class="account-list">
{#each accounts as account} {#if $accounts}
{#each $accounts as account}
<AccountListItem account={account} /> <AccountListItem account={account} />
{/each} {/each}
<AccountAddButton
onclick={() => { addAccount() }}
onkeydown={(event: KeyboardEvent) => {
if (event.key === "Enter") addAccount()
}}
/>
{:else}
{#each {length: 4} as i}
<Skeleton><div class="account-skeleton"></div></Skeleton>
{/each}
{/if}
</div> </div>
</main> </main>
</div> </div>
@ -48,8 +64,15 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: row;
flex-wrap: wrap;
gap: 1em; gap: 1em;
} }
.account-skeleton {
width: 480px;
height: var(--sp-xxxl);
padding: .5em;
}
</style> </style>

View file

@ -10,7 +10,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "nodenext" "moduleResolution": "bundler"
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files