diff --git a/apps/main-app/public/editor/queries.ts b/apps/main-app/public/editor/queries.ts index e93fa4f..0b21cea 100644 --- a/apps/main-app/public/editor/queries.ts +++ b/apps/main-app/public/editor/queries.ts @@ -1,6 +1,7 @@ import { QueryClient, type QueryKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, api, errorText } from './api' import { newestFirst, type Site, toPrivateSite, toPublicSite, toWebhook } from './model' +import { rememberAccount } from './remembered-accounts' import { notify } from './store' export const queryClient = new QueryClient({ @@ -28,7 +29,12 @@ export const keys = { } as const export const useUser = () => - useQuery({ queryKey: keys.user, queryFn: api.user, retry: false, staleTime: Number.POSITIVE_INFINITY }) + useQuery({ + queryKey: keys.user, + queryFn: () => api.user().then(rememberAccount), + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }) async function fetchSites(): Promise { // Private sites are an extra; a failure there must not hide the public list. diff --git a/apps/main-app/public/editor/remembered-accounts.ts b/apps/main-app/public/editor/remembered-accounts.ts new file mode 100644 index 0000000..8daf598 --- /dev/null +++ b/apps/main-app/public/editor/remembered-accounts.ts @@ -0,0 +1,32 @@ +import type { UserInfo } from './api' + +/** + * Accounts that signed in on this device, newest first, for the landing + * page's account picker. Shared with landingpage.html by key and shape. + */ +const KEY = 'wisp-accounts' +const LIMIT = 5 + +interface RememberedAccount { + did: string + handle: string +} + +const read = (): RememberedAccount[] => { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]') + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +/** Remember the signed-in account; storage failures only cost the shortcut. */ +export const rememberAccount = (user: UserInfo): UserInfo => { + if (user.handle === 'unknown') return user + const accounts = [{ did: user.did, handle: user.handle }, ...read().filter((a) => a.did !== user.did)] + try { + localStorage.setItem(KEY, JSON.stringify(accounts.slice(0, LIMIT))) + } catch {} + return user +} diff --git a/apps/main-app/public/landingpage.html b/apps/main-app/public/landingpage.html index 46ab6b1..9c826ef 100644 --- a/apps/main-app/public/landingpage.html +++ b/apps/main-app/public/landingpage.html @@ -175,6 +175,8 @@ } main, header, footer { position: relative; z-index: 1; } + /* the tilted marquee is wider than a phone; without this, mobile browsers widen the layout viewport */ + main { overflow-x: clip; } header { background: color-mix(in oklch, var(--paper) 82%, transparent); @@ -703,6 +705,164 @@ .sticky .big { font-family: var(--hand); font-size: 2.1rem; font-weight: 700; line-height: 1; margin-bottom: 0.6rem; display: block; } .sticky p { font-size: 0.95rem; line-height: 1.55; margin: 0; color: oklch(0.26 0.031 352); font-weight: 500; } + /* ------------------------------------------------------------------ + Sign-in / sign-up dialog + ------------------------------------------------------------------ */ + .door-dialog { + margin: auto; + width: min(40rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + padding: 1.6rem; + border: 3px solid var(--line); + border-radius: 22px; + background: var(--paper-2); + color: var(--ink); + box-shadow: 6px 6px 0 var(--shadow); + overflow: auto; + } + .door-dialog [tabindex="-1"] { outline: none; } + .door-dialog :focus-visible:not(input, [tabindex="-1"]) { outline: 2px solid var(--rose); outline-offset: 2px; } + .door-dialog.narrow { width: min(27rem, calc(100vw - 2rem)); background: var(--paper); } + .door-dialog[open] { animation: door-pop 280ms cubic-bezier(.34,1.56,.64,1) both; } + .door-dialog::backdrop { background: color-mix(in oklch, oklch(0.26 0.031 352) 30%, transparent); backdrop-filter: blur(3px); } + @keyframes door-pop { from { opacity: 0; scale: 0.94; } } + .door-dialog .panel { animation: door-panel 320ms cubic-bezier(0.22, 1, 0.36, 1) both; } + .door-dialog .panel.from-back { animation-name: door-panel-back; } + @keyframes door-panel { from { opacity: 0; transform: translateX(14px); } } + @keyframes door-panel-back { from { opacity: 0; transform: translateX(-14px); } } + + .door-close { + position: absolute; top: 0.7rem; right: 0.7rem; z-index: 2; + width: 2rem; height: 2rem; padding: 0; + display: grid; place-items: center; + border: 2px solid var(--line); border-radius: 50%; + background: var(--card); color: var(--ink); cursor: pointer; font-size: 1rem; line-height: 1; + } + .door-back { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.2rem 0.1rem; border: 0; background: none; cursor: pointer; + color: var(--ink-soft); font: inherit; font-size: 0.9rem; font-weight: 600; + } + .door-back:hover { color: var(--ink); } + .door-link { + padding: 0; border: 0; background: none; cursor: pointer; font: inherit; font-weight: 600; + color: var(--rose); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 3px; + } + .door-title { + font-family: var(--display); font-variation-settings: "SOFT" 100, "WONK" 1; + font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0.2rem 0 0.6rem; + } + .door-heading { text-align: center; padding: 0 2rem; } + .door-heading .door-title { font-size: clamp(1.6rem, 4vw, 2rem); } + .door-dialog .kicker { font-size: 1.45rem; margin: 0; } + .door-body { color: var(--ink-soft); font-size: 0.95rem; margin: 0 0 1.1rem; } + .door-fine { color: var(--ink-soft); font-size: 0.8rem; margin: 0.8rem 0 0; } + .door-center { text-align: center; } + .door-stack { display: grid; gap: 0.6rem; } + .door-or { + display: flex; align-items: center; gap: 0.75rem; margin: 1rem 0; + color: var(--ink-soft); font-family: var(--hand); font-size: 1.25rem; + } + .door-or::before, .door-or::after { content: ""; flex: 1; border-top: 2px dashed var(--border-light); } + + .doors { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; margin: 1.2rem 0 0.4rem; } + .door { + display: flex; flex-direction: column; align-items: center; min-height: 11rem; padding: 1.1rem 1.1rem 1rem; + text-decoration: none; + text-align: left; font: inherit; cursor: pointer; color: oklch(0.26 0.031 352); + background: var(--c); border: 2.5px solid var(--line); border-radius: 4px 4px 18px 4px; + box-shadow: 4px 5px 0 var(--shadow); rotate: var(--r); + transition: rotate 300ms cubic-bezier(.34,1.56,.64,1), transform 300ms cubic-bezier(.34,1.56,.64,1), box-shadow 200ms ease; + } + .door:hover, .door:focus-visible { rotate: 0deg; transform: translateY(-4px); box-shadow: 6px 8px 0 var(--shadow); } + .door .tape { + width: 4.5rem; height: 1.1rem; margin: -1.6rem auto 0.5rem; rotate: -3deg; + background: color-mix(in oklch, var(--paper) 65%, transparent); + border: 1px dashed oklch(0.26 0.031 352 / 0.3); + } + .door .big { font-family: var(--hand); font-size: 2.1rem; font-weight: 700; line-height: 1; margin-bottom: 0.5rem; } + .door p { margin: 0 0 1rem; font-size: 0.95rem; color: oklch(0.32 0.04 352); text-align: center; } + .door .go { margin-top: auto; font-weight: 800; font-size: 0.95rem; } + + .door-info { + position: relative; display: inline-grid; place-items: center; + width: 1.05rem; height: 1.05rem; margin-left: 0.2rem; vertical-align: 0.1em; + border: 1.5px solid currentColor; border-radius: 50%; + font-family: var(--display); font-style: italic; font-weight: 700; font-size: 0.68rem; line-height: 1; + cursor: help; + } + .door-info::after { + content: attr(data-tip); + position: absolute; bottom: calc(100% + 0.45rem); left: 50%; translate: -50% 4px; z-index: 3; + width: max-content; max-width: 14rem; padding: 0.4rem 0.6rem; white-space: normal; text-align: center; + border: 2px solid var(--line); border-radius: 10px; background: var(--card); color: var(--ink); + box-shadow: 2px 2px 0 var(--shadow); + font-family: var(--hand); font-style: normal; font-weight: 500; font-size: 1.1rem; line-height: 1.15; + opacity: 0; pointer-events: none; transition: opacity 150ms ease, translate 150ms ease; + } + .door-info.end::after { left: auto; right: -0.5rem; translate: 0 4px; } + .door-info.end:hover::after, .door-info.end:focus::after { translate: 0 0; } + .door-dialog .nowrap { white-space: nowrap; } + .door-info:hover::after, .door-info:focus-visible::after, .door-info:focus::after { opacity: 1; translate: -50% 0; } + .door-dialog .btn-pop.block { width: 100%; justify-content: center; } + .row-card { + position: relative; display: flex; align-items: center; gap: 0.8rem; width: 100%; padding: 0.7rem 0.8rem; + text-align: left; font: inherit; color: var(--ink); cursor: pointer; + border: 2px solid var(--line); border-radius: 14px; background: var(--card); box-shadow: 2px 2px 0 var(--shadow); + transition: transform 160ms ease, box-shadow 160ms ease; + } + .row-card:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--shadow); } + .row-card.ghost { border-style: dashed; background: transparent; box-shadow: none; } + .row-avatar { + flex: none; display: grid; place-items: center; width: 2.4rem; height: 2.4rem; + border: 2px solid var(--line); border-radius: 50%; background: var(--c, var(--pink)); + color: oklch(0.26 0.031 352); font-family: var(--display); font-weight: 800; + } + .row-main { flex: 1; min-width: 0; display: grid; } + .row-main b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .row-forget { + flex: none; width: 1.7rem; height: 1.7rem; padding: 0; border: 0; border-radius: 50%; + background: transparent; color: var(--ink-soft); cursor: pointer; + } + .row-forget:hover { background: var(--paper-2); color: var(--ink); } + .saved-row { display: flex; align-items: center; gap: 0.3rem; } + .saved-row .row-card { flex: 1; } + + .door-label { display: block; margin-bottom: 0.35rem; font-weight: 700; font-size: 0.9rem; } + .door-field { + display: flex; align-items: center; border: 2px solid var(--line); border-radius: 14px; background: var(--card); + box-shadow: inset 2px 2px 0 color-mix(in oklch, var(--shadow) 45%, transparent); + } + .door-field:focus-within { box-shadow: 0 0 0 3px color-mix(in oklch, var(--pink) 70%, transparent); } + .door-field .at { padding-left: 0.85rem; color: var(--ink-soft); font-weight: 600; } + .door-field input { + flex: 1; min-width: 0; padding: 0.75rem 0.85rem 0.75rem 0.25rem; + border: 0; outline: none; background: transparent; color: var(--ink); font: inherit; font-size: 1rem; + } + .door-field.bad { border-color: oklch(0.55 0.18 25); } + .door-error { + margin: 0.6rem 0 0; padding: 0.55rem 0.75rem; border-radius: 12px; + background: oklch(0.95 0.04 25); color: oklch(0.50 0.18 25); font-size: 0.85rem; font-weight: 600; + } + :root[data-theme="dark"] .door-error { background: oklch(0.30 0.06 25); color: oklch(0.82 0.11 25); } + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .door-error { background: oklch(0.30 0.06 25); color: oklch(0.82 0.11 25); } + } + + .door-going { display: grid; justify-items: center; padding: 1.5rem 0.5rem 1rem; text-align: center; } + .door-going img { width: 92px; animation: going-bob 1.6s ease-in-out infinite; } + @keyframes going-bob { 50% { transform: translateY(-8px) rotate(-6deg); } } + .door-going .host { padding: 0.15rem 0.55rem; border-radius: 6px; background: var(--paper-2); font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.85em; } + + @media (max-width: 560px) { + .doors { grid-template-columns: 1fr; } + .door { min-height: 0; } + } + @media (prefers-reduced-motion: reduce) { + .door-dialog[open], .door-dialog .panel, .door-going img { animation: none; } + .door { transition: none; } + } + /* ------------------------------------------------------------------ CLI ------------------------------------------------------------------ */ @@ -837,7 +997,7 @@ Blog Status - Sign in ✦ + Sign in ✦ @@ -854,7 +1014,7 @@ A folder becomes a website.

- Upload a folder → + Upload a folder → Read the docs

✿ free, no card, run by one person for fun

@@ -896,7 +1056,7 @@
- make it real ✦ + make it real ✦
@@ -998,6 +1158,68 @@ + + + +
+
+ hello ✦ +

Have you been here before?

+
+
+ + + nope, new! +

Make a free account. It takes a minute.

+ Sign up → +
+ +
+

Your account will work on services wisp natively integrates with, like Tangledi, ATCR.ioi and marque.ati.

+
+ + + + + + +
+