hyperfixated on the popovers again

Changed files
+277 -153
src
+7
bun.lock
···
"@astrojs/db": "^0.17.1",
"@astrojs/node": "^9.4.3",
"@atproto/api": "^0.16.7",
+
"@floating-ui/dom": "^1.7.4",
"@fujocoded/authproto": "^0.0.4",
"@lucide/astro": "^0.542.0",
"@tailwindcss/vite": "^4.1.13",
···
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
+
+
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
+
+
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
+
+
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fujocoded/authproto": ["@fujocoded/authproto@0.0.4", "", { "dependencies": { "@astrojs/db": "^0.17.1", "@atproto/identity": "^0.4.8", "@atproto/oauth-client-node": "^0.3.3", "astro-integration-kit": "^0.19.0", "unstorage": "^1.16.1" }, "peerDependencies": { "astro": "^5.13.0" } }, "sha512-VoKfScLMaGLAOB6WKFsU7lEsCNvy98KW7MaPfqLKIbjg0MqWrbeyvCak4mGDjQUe96jm8DBMkd6b2W+T369PNA=="],
+1
package.json
···
"@astrojs/db": "^0.17.1",
"@astrojs/node": "^9.4.3",
"@atproto/api": "^0.16.7",
+
"@floating-ui/dom": "^1.7.4",
"@fujocoded/authproto": "^0.0.4",
"@lucide/astro": "^0.542.0",
"@tailwindcss/vite": "^4.1.13",
+22 -2
src/assets/styles/global.css
···
@import "tailwindcss";
-
@plugin "daisyui";
+
@plugin "daisyui" {
+
themes: all;
+
/* add new themes here */
+
}
@plugin "@tailwindcss/typography";
+
/* default theme */
@theme {
/* font tokens */
--font-sans: var(--atkinson);
···
--text-7xl: clamp(7.4506rem, 71.4115rem + -82.5302cqi, 52.8422rem);
--text-8xl: clamp(9.3132rem, 116.6654rem + -138.5189cqi, 85.4986rem);
--text-9xl: clamp(11.6415rem, 190.1667rem + -230.355cqi, 138.3368rem);
-
}
+
}
+
+
@custom-variant dark (&:where(
+
[data-theme=dark],
+
[data-theme=dracula],
+
[data-theme=synthwave],
+
[data-theme=halloween],
+
[data-theme=forest],
+
[data-theme=aqua],
+
[data-theme=black],
+
[data-theme=luxury],
+
[data-theme=business],
+
[data-theme=night],
+
[data-theme=coffee],
+
[data-theme=sunset],
+
[data-theme=abyss]
+
));
+13 -11
src/components/Dialog.astro
···
<dialog
{id}
class:list={[
-
"m-auto",
-
"rounded-box",
-
"shadow"
-
, className,
+
"modal modal-bottom sm:modal-middle",
+
className,
]}
role={alert ? "alertdialog" : undefined}
closedby="any"
>
-
<div class="card">
-
<header class="flex items-center justify-between">
-
<h1 class="card-title">{title}</h1>
+
<div class="modal-box">
+
<header class="modal-header">
+
<h1 class="text-lg leading-none flex-1 pl-2">{title}</h1>
<form method="dialog">
-
<button aria-label="close" class="close">
+
<button aria-label="close" class="btn btn-error">
<X />
</button>
</form>
</header>
-
-
<div class="card-body">
+
+
<div class="mt-14">
<slot />
</div>
</div>
</dialog>
<style>
-
+
@reference "../assets/styles/global.css";
+
+
.modal-header {
+
@apply absolute top-0 left-0 flex items-center justify-between p-2 container bg-accent text-accent-content;
+
}
</style>
+62 -24
src/components/Popover.astro
···
interface Props {
id?: string;
label: string;
-
direction: "top" | "bottom";
icon?: "info" | "warning" | "danger";
title?: string;
class?: string;
}
-
const { id, label, direction = "top", icon, title, class: className, ...rest } = Astro.props;
+
const { id, label, icon, title, class: className, ...rest } = Astro.props;
---
<!-- type button needs to be set here, otherwise it doesn't work inside forms -->
<button
type="button"
id={`${id}-trigger`}
class:list={[
-
"btn",
-
"btn-xs",
+
"btn btn-xs",
icon && ["btn-circle", "btn-ghost"],
icon &&
(icon === "info") ? "text-info" :
···
<div
{id}
class:list={[
-
"dropdown",
-
"card",
-
"bg-base-100",
-
"w-72",
-
"shadow",
"popover-content",
className,
]}
-
role="tooltip" {...rest}
-
popover="auto"
+
role="tooltip"
+
popover="auto"
+
{...rest}
>
<div class="card-body">
{title && (
···
</div>
</div>
-
<style define:vars={{ anchor: `--${id}-anchor`, direction }}>
+
<style define:vars={{ anchor: `--${id}-anchor` }}>
+
@reference "../assets/styles/global.css";
+
.popover-btn {
-
anchor-name: var(--anchor);
+
@supports (anchor-name: var(--anchor)) {
+
anchor-name: var(--anchor);
+
}
}
.popover-content {
-
position-anchor: var(--anchor);
-
top: anchor(var(--direction));
-
left: anchor(center);
-
transform: translateX(-50%);
-
position-try-fallbacks: flip-block, flip-inline;
+
@apply dropdown card mx-0 inset-auto bg-base-100 w-72 shadow;
+
+
@supports (position-anchor: var(--anchor)) and (left: anchor(center)) {
+
position-anchor: var(--anchor);
+
left: anchor(center);
+
transform: translateX(-50%);
+
}
}
</style>
-
<script define:vars={{ id }} is:inline>
-
const trigger = document.getElementById(`${id}-trigger`);
-
const popover = document.getElementById(id);
+
<script>
+
import { computePosition, autoUpdate, shift, flip } from "@floating-ui/dom";
+
const triggers = document.querySelectorAll(".popover-btn");
+
+
triggers.forEach(trigger => {
+
const btn = trigger as HTMLButtonElement;
+
// triggering button will always end with "-trigger"
+
// so slice that from the id
+
const id = btn.id.slice(0, -8);
+
const popover = document.getElementById(`${id}`) as HTMLElement;
+
+
btn.addEventListener("click", (e) => {
+
e.preventDefault();
+
popover.togglePopover();
+
});
-
trigger.addEventListener("click", (e) => {
-
e.preventDefault();
-
popover.togglePopover();
+
popover.addEventListener("toggle", (e) => {
+
const cleanup = autoUpdate(
+
btn,
+
popover,
+
() => {
+
computePosition(btn, popover, {
+
middleware: [
+
flip(),
+
shift({
+
crossAxis: false,
+
}),
+
],
+
}).then(({ placement, middlewareData }) => {
+
Object.assign(popover.style, {
+
top: `anchor(${placement})`,
+
...(placement === "top") && {
+
transform: (middlewareData.shift?.enabled.x)
+
? `translate(calc(-50% + ${middlewareData.shift.x}px), -100%)`
+
: `translate(-50%, -100%)`,
+
},
+
});
+
});
+
});
+
if (e.newState === "open") {
+
cleanup;
+
} else {
+
cleanup();
+
}
+
});
});
</script>
+44 -21
src/components/Settings.astro
···
---
<Dialog id="settings" title="User preferences">
<form id="user-settings">
-
<label for="font-family">font family</label>
-
<select name="fontFamily" id="font-family">
-
<option value="default">choose...</option>
-
<option value="--serif">serif</option>
-
<option value="--mono">monospaced</option>
-
<option value="--sans">sans serif</option>
-
<option value="--dyslexic">dyslexic</option>
-
</select>
+
<fieldset class="fieldset">
+
<label for="font-family">font family</label>
+
<select class="select" name="fontFamily" id="font-family">
+
<option value="default">choose...</option>
+
<option value="--font-serif">serif</option>
+
<option value="--font-mono">monospaced</option>
+
<option value="--font-sans">sans serif</option>
+
<option value="--font-dyslexic">dyslexic</option>
+
</select>
+
</fieldset>
-
<label for="font-size">text size</label>
-
<input type="range" name="fontSize" id="font-size" min="-1" max="2" step="1" />
+
<fieldset class="fieldset">
+
<label for="font-size">text size</label>
+
<input class="range" type="range" name="fontSize" id="font-size" min="-1" max="2" step="1" />
+
</fieldset>
-
<label for="line-height">line height</label>
-
<input type="range" name="lineHeight" id="line-height" min="1" max="2" step="0.05" />
+
<fieldset class="fieldset">
+
<label for="line-height">line height</label>
+
<input class="range" type="range" name="lineHeight" id="line-height" min="1" max="2" step="0.05" />
+
</fieldset>
+
+
<fieldset class="fieldset">
+
<label for="letter-spacing">letter spacing</label>
+
<input class="range" type="range" name="letterSpacing" id="letter-spacing" min="0" max="0.1" step="0.01" />
+
</fieldset>
-
<label for="letter-spacing">letter spacing</label>
-
<input type="range" name="letterSpacing" id="letter-spacing" min="0" max="0.1" step="0.01" />
-
-
<label for="word-spacing">word spacing</label>
-
<input type="range" name="wordSpacing" id="word-spacing" min="0" max="0.5" step="0.01" />
+
<fieldset class="fieldset">
+
<label for="word-spacing">word spacing</label>
+
<input class="range" type="range" name="wordSpacing" id="word-spacing" min="0" max="0.5" step="0.01" />
+
</fieldset>
-
<div id="test-area">
+
<div id="test-area" class="mt-4 text-(length:--size) leading-(--line-height) tracking-(--letter-spacing) line-clamp-4">
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Asperiores quae dolorum debitis vero nostrum nobis aspernatur ipsam sunt dolorem, eum ut corrupti unde commodi soluta natus repellendus totam animi adipisci.</p>
</div>
-
<button id="confirm-settings">save</button>
+
<div class="modal-action">
+
<button formmethod="dialog" value="default" class="btn btn-neutral">Cancel</button>
+
<button id="confirm-settings" class="btn btn-primary">Save</button>
+
</div>
</form>
</Dialog>
<style>
#test-area {
-
--font: var(--body);
+
--step--1: clamp(0.6953rem, 0.5707rem + 0.554vw, 1rem);
+
--step-0: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
+
--step-1: clamp(1.5625rem, 1.9257rem + -0.4686vw, 1.8203rem);
+
--step-2: clamp(1.9531rem, 3.351rem + -1.8037vw, 2.9452rem);
+
+
--font: var(--font-sans);
--size: var(--step-0);
--letter-spacing: 0em;
--word-spacing: 0em;
···
const test = document.getElementById("test-area");
form?.addEventListener("submit", (e) => {
-
e.preventDefault();
+
const target = e.target as HTMLFormElement;
+
if (target.nodeValue === null) {
+
return;
+
} else {
+
e.preventDefault();
+
}
});
inputs.forEach((input) => {
+30 -17
src/layouts/WorkPage.astro
···
import Layout from "./Layout.astro";
interface Props {
+
slug: string;
title: string;
-
has_previous: boolean;
-
has_next: boolean;
+
author: string;
+
// tags: Tag[];
+
tags: any;
+
createdAt: Date;
+
updatedAt?: Date | null;
+
comments?: boolean;
+
previous?: boolean;
+
next?: boolean;
}
-
const { title, has_previous, has_next } = Astro.props;
+
const { slug, title, author, tags, createdAt, updatedAt, comments, previous, next } = Astro.props;
---
-
<Layout title={title}>
-
<a href="#workname-content">to content</a>
-
+
<Layout title={title} skipLink="work-body">
<nav id="work-menu">
-
{has_previous && (
+
{previous && (
// chapterid - 1?
<a href="">previous chaptertitle</a>
)}
<!-- if theres more than one chapter, render this box -->
-
<select name="workname-chapters" id="workname-chapters">
+
<select name="chapterSelect" id={`${slug}-chapters`}>
<option value="default" selected>Choose chapter...</option>
<!-- map each chapter here -->
</select>
-
{has_next && (
+
{next && (
// chapterid + 1 ?
<a href="">next chaptertitle</a>
)}
</nav>
-
<main>
+
<main id="work-body">
<header>
<h1>{title}</h1>
-
<h2>author name</h2>
+
<h2>{author}</h2>
+
<!-- replace this at some point -->
+
{JSON.stringify(tags)}
+
<time datetime={createdAt.toISOString()}>{createdAt}</time>
+
{updatedAt && (
+
<time datetime={updatedAt.toISOString()}>{updatedAt}</time>
+
)}
<div id="summary">
summary
</div>
</header>
-
<section id="workname-content">
+
<section id={`${slug}-content`} class="prose lg:prose-xl">
<!-- if work has its own style, render it here somehow -->
<details>
<summary>Author's notes</summary>
this should include author's notes
</details>
-
+
<slot />
</section>
-
<aside id="workname-comments">
-
<!-- use bsky api to render comments here -->
-
<!-- paginate this -->
-
</aside>
+
{comments && (
+
<aside id={`${slug}-comments`}>
+
<!-- use bsky api to render comments here -->
+
<!-- paginate this -->
+
</aside>
+
)}
</main>
</Layout>
+1 -1
src/pages/login.astro
···
<fieldset class="fieldset mx-auto place-content-center max-w-md">
<label class="fieldset-label" for="handle">
Input your handle
-
<Popover id="handle-help" icon="info" label="help" direction="bottom">
+
<Popover id="handle-help" icon="info" label="help">
<h3>What's my handle?</h3>
<p>It'll look like a website URL without the <samp>https://</samp> or slashes, so a typical BlueSky handle will look something like: <b>alice.bsky.social</b>.</p>
<p>What yours will look like depends on whether you made a custom handle!</p>
+84 -61
src/pages/user/index.astro
···
---
import Layout from "@/layouts/Layout.astro";
+
import { Info } from "@lucide/astro";
import { actions } from "astro:actions";
import { db, eq, Users, Works } from "astro:db";
import Dialog from "~/Dialog.astro";
import Popover from "~/Popover.astro";
+
import Settings from "~/Settings.astro";
const loggedInUser = Astro.locals.loggedInUser;
···
.from(Works)
.where(eq(Works.author, user?.userDid ?? loggedInUser.did));
---
-
<Layout>
-
<h1>User Settings</h1>
-
<p>{loggedInUser?.handle}</p>
-
-
<!-- registration will only happen in the below form! -->
-
{(query.length === 0) && (
-
<>
-
<h2>Connect account</h2>
-
<div class="info">
-
<p>Right now, you aren't connected to the site. You can connect your BlueSky / self-hosted PDS account to this website to post a work.</p>
-
<p>Please check out the Terms of Service, Privacy Policy, and Code of Conduct before connecting your account.</p>
-
</div>
-
<button id="trigger-confirm">Connect your PDS Account</button>
-
-
<Dialog id="connect-account" title="Are you sure?">
-
<form action={actions.usersActions.addUser} method="post">
-
<label for="nickname">Nickname</label>
-
<Popover id="nickname-info" label="info" icon="warning" direction="bottom">
-
<p>You can optionally set your nickname for this site. This is separate from your handle and acts as your identifier.</p>
-
<p>Think of your handle as what you use to log in with, and your nickname as the name you want to publish your works under.</p>
-
<h3>Important</h3>
-
<p>If you do set a nickname, please make sure it's unique! Having two people with the same nickname would cause confusion, unfortunately.</p>
-
</Popover>
-
<input type="text" name="nickname" id="nickname" />
+
<Layout skipLink="user-profile">
+
<main id="user-profile">
+
<h1 class="text-xl">User Settings</h1>
+
<p>{loggedInUser?.handle}</p>
+
+
<!-- registration will only happen in the below form! -->
+
{(query.length === 0) && (
+
<>
+
<h2 class="text-lg">Connect account</h2>
+
<div class="info">
+
<p>Right now, you aren't connected to the site. You can connect your BlueSky / self-hosted PDS account to this website to post a work.</p>
+
<p>Please check out the Terms of Service, Privacy Policy, and Code of Conduct before connecting your account.</p>
+
</div>
+
<button id="trigger-confirm" class="btn btn-accent">Connect your PDS Account</button>
+
+
<Dialog id="connect-account" title="Are you sure?">
+
<form action={actions.usersActions.addUser} method="post">
+
<fieldset class="fieldset">
+
<div class="flex gap-1">
+
<label for="nickname" class="label">Nickname</label>
+
<Popover id="nickname-note" label="info" icon="warning" title="Important">
+
<p>If you do set a nickname, please make sure it's unique! Having two people with the same nickname would cause confusion, unfortunately.</p>
+
</Popover>
+
</div>
+
<input class="input w-full" type="text" name="nickname" id="nickname" aria-describedby="nickname-info" />
+
<div id="nickname-info" class="alert">
+
<Info class="text-info" />
+
<div>
+
<p>You can optionally set your nickname for this site.</p>
+
<p>This is separate from your handle and acts similarly to a penname.</p>
+
</div>
+
</div>
+
</fieldset>
-
<button formmethod="dialog">Cancel</button>
-
<button>Confirm</button>
-
</form>
-
</Dialog>
-
</>
-
)}
-
-
{user && (
-
<>
-
<h2>your nickname???</h2>
-
<time datetime={user.joinedAt.toISOString()}>{user.joinedAt}</time>
-
<p>{user.userDid}</p>
+
<div class="modal-action">
+
<button class="btn btn-neutral" formmethod="dialog">Cancel</button>
+
<button class="btn btn-primary">Confirm</button>
+
</div>
+
</form>
+
</Dialog>
+
</>
+
)}
+
+
{user && (
+
<>
+
<h2>your nickname???</h2>
+
<time datetime={user.joinedAt.toISOString()}>{user.joinedAt}</time>
+
<p>{user.userDid}</p>
-
{works && (
-
<section>
-
<ul>
-
{works.map(work => (
-
<article>
-
<h3>{work.title}</h3>
+
{works && (
+
<section>
+
<ul>
+
{works.map(work => (
+
<article>
+
<h3>{work.title}</h3>
-
<time datetime={work.createdAt.toISOString()}>
-
{work.createdAt}
-
</time>
-
{work.updatedAt && (
-
<time datetime={work.updatedAt.toISOString()}>
-
{work.updatedAt}
+
<time datetime={work.createdAt.toISOString()}>
+
{work.createdAt}
</time>
-
)}
+
{work.updatedAt && (
+
<time datetime={work.updatedAt.toISOString()}>
+
{work.updatedAt}
+
</time>
+
)}
-
<ul>
-
{JSON.stringify(work.tags)}
-
</ul>
-
-
summary here
-
</article>
-
))}
-
</ul>
-
</section>
-
)}
-
</>
-
)}
+
<ul>
+
{JSON.stringify(work.tags)}
+
</ul>
+
+
summary here
+
</article>
+
))}
+
</ul>
+
</section>
+
)}
+
</>
+
)}
+
+
<button class="btn btn-primary" id="trigger-settings">Set preferences</button>
+
<Settings />
+
</main>
</Layout>
<script>
const trigger = document.getElementById("trigger-confirm");
+
const trigger2 = document.getElementById("trigger-settings");
const confirmDialog = document.getElementById("connect-account") as HTMLDialogElement;
+
const settingsDialog = document.getElementById("settings") as HTMLDialogElement;
trigger?.addEventListener("click", (_) => {
confirmDialog.showModal();
+
});
+
+
trigger2?.addEventListener("click", (_) => {
+
settingsDialog.showModal();
});
</script>
+13 -16
src/pages/works/[workId].astro
···
---
-
import Layout from "@/layouts/Layout.astro";
+
import WorkPage from "@/layouts/WorkPage.astro";
import { didToHandle } from "@/lib/atproto";
-
import type { Tag } from "@/lib/types";
import { db, eq, Users, Works } from "astro:db";
const { workId } = Astro.params;
···
return Astro.redirect("/not-found");
}
---
-
<Layout>
-
{work.map(async ({ Works, Users }) => (
-
<>
-
<h1>{Works.title}</h1>
-
<h2>{await didToHandle(Users.userDid)}</h2>
-
<time datetime={Works.createdAt.toISOString()}>{Works.createdAt}</time>
-
{(Works.tags as Tag[]).map(tag => (
-
<a href={tag.url}>{tag.label}</a>
-
))}
-
-
<Fragment set:html={Works.content} />
-
</>
-
))}
-
</Layout>
+
{work.map(async ({ Works, Users }) => (
+
<WorkPage
+
slug={Works.slug}
+
title={Works.title}
+
author={await didToHandle(Users.userDid)}
+
createdAt={Works.createdAt}
+
updatedAt={Works.updatedAt}
+
tags={Works.tags}
+
>
+
<Fragment set:html={Works.content} />
+
</WorkPage>
+
))}