too lazy to think of a message, so enjoy this pointless text. Good luck future me...
All checks were successful
Test action / Print hello world (push) Successful in 6m12s

This commit is contained in:
Chris Kruining 2025-09-22 14:25:07 +00:00
parent a502a50176
commit ff31c28d38
46 changed files with 1403 additions and 1397 deletions

0
bun.lock Normal file → Executable file
View file

12
bunfig.toml Normal file → Executable file
View file

@ -1,6 +1,6 @@
[test] [test]
coverage = true coverage = true
coverageSkipTestFiles = true coverageSkipTestFiles = true
coverageReporter = ['text', 'lcov'] coverageReporter = ['text', 'lcov']
coverageDir = './.coverage' coverageDir = './.coverage'
preload = "./test.config.ts" preload = "./test.config.ts"

0
flake.nix Normal file → Executable file
View file

6
justfile Executable file
View file

@ -0,0 +1,6 @@
push:
git add .
git commit -m 'too lazy to think of a message, so enjoy this pointless text. Good luck future me...'
git push

0
nix/devShells/flake-module.nix Normal file → Executable file
View file

2
nix/modules/customer-portal/default.nix Normal file → Executable file
View file

@ -77,7 +77,7 @@ in
config = mkIf cfg.enable { config = mkIf cfg.enable {
systemd = { systemd = {
servces.amarthCustomerPortal = { services.amarthCustomerPortal = {
after = [ "network.target" ]; after = [ "network.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];

0
nix/modules/customer-portal/flake-module.nix Normal file → Executable file
View file

0
nix/modules/flake-module.nix Normal file → Executable file
View file

0
nix/packages/flake-module.nix Normal file → Executable file
View file

0
src/app.css Normal file → Executable file
View file

10
src/auth.client.ts Normal file → Executable file
View file

@ -1,6 +1,6 @@
import { createAuthClient } from "better-auth/solid"; import { createAuthClient } from "better-auth/solid";
import { genericOAuthClient } from "better-auth/client/plugins"; import { genericOAuthClient } from "better-auth/client/plugins";
export const { signIn, signOut, useSession, ...client } = createAuthClient({ export const { signIn, signOut, useSession, ...client } = createAuthClient({
plugins: [genericOAuthClient()], plugins: [genericOAuthClient()],
}); });

102
src/auth.server.ts Normal file → Executable file
View file

@ -1,51 +1,51 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
export const auth = betterAuth({ export const auth = betterAuth({
appName: "Streamarr", appName: "Streamarr",
basePath: "/api/auth", basePath: "/api/auth",
database: new Database('auth.sqlite', { create: true }), database: new Database('auth.sqlite', { create: true }),
logger: { logger: {
level: "debug", level: "debug",
log(level, message, ...args) { log(level, message, ...args) {
console.log(level, message, {args}); console.log(level, message, {args});
}, },
}, },
user: { user: {
additionalFields: { additionalFields: {
name: { name: {
type: "string", type: "string",
nullable: true, nullable: true,
}, },
username: { username: {
type: "string", type: "string",
nullable: true, nullable: true,
}, },
}, },
}, },
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
{ {
providerId: "zitadel", providerId: "zitadel",
clientId: "", clientId: "",
clientSecret: "", clientSecret: "",
discoveryUrl: "https://auth.amarth.cloud/.well-known/openid-configuration", discoveryUrl: "https://auth.amarth.cloud/.well-known/openid-configuration",
scopes: [ scopes: [
"offline_access", "offline_access",
"openid", "openid",
"email", "email",
"picture", "picture",
"profile", "profile",
"groups", "groups",
], ],
accessType: "offline", accessType: "offline",
pkce: true, pkce: true,
mapProfileToUser: ({ id, name, email, image, preferred_username, emailVerified }) => mapProfileToUser: ({ id, name, email, image, preferred_username, emailVerified }) =>
({ id, name, email, emailVerified, image, username: preferred_username }), ({ id, name, email, emailVerified, image, username: preferred_username }),
}, },
], ],
}), }),
], ],
}); });

66
src/components/details/details.module.css Normal file → Executable file
View file

@ -1,33 +1,33 @@
.container { .container {
isolation: isolate; isolation: isolate;
display: block grid; display: block grid;
container-type: inline-size; container-type: inline-size;
} }
.header { .header {
position: relative; position: relative;
block-size: 80cqb; block-size: 80cqb;
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
display: block; display: block;
background: linear-gradient( background: linear-gradient(
atan(var(--ratio, .2)), atan(var(--ratio, .2)),
var(--surface-2) 20em, var(--surface-2) 20em,
transparent 90% transparent 90%
); );
} }
& > .background { & > .background {
position: absolute; position: absolute;
inset: 0; inset: 0;
block-size: 100%; block-size: 100%;
inline-size: 100%; inline-size: 100%;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
z-index: 0; z-index: 0;
} }
} }

70
src/components/details/details.tsx Normal file → Executable file
View file

@ -1,35 +1,35 @@
import { Component, createSignal, onCleanup, onMount } from "solid-js"; import { Component, createSignal, onCleanup, onMount } from "solid-js";
import { Entry } from "~/features/content"; import { Entry } from "~/features/content";
import css from "./details.module.css"; import css from "./details.module.css";
interface DetailsProps { interface DetailsProps {
entry: Entry; entry: Entry;
} }
export const Details: Component<DetailsProps> = (props) => { export const Details: Component<DetailsProps> = (props) => {
const [header, setHeader] = createSignal<HTMLElement>(); const [header, setHeader] = createSignal<HTMLElement>();
onMount(() => { onMount(() => {
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
const { inlineSize, blockSize } = entry.contentBoxSize[0]; const { inlineSize, blockSize } = entry.contentBoxSize[0];
(entry.target as HTMLElement).style.setProperty( (entry.target as HTMLElement).style.setProperty(
"--ratio", "--ratio",
String((blockSize * 0.2) / inlineSize) String((blockSize * 0.2) / inlineSize)
); );
}); });
observer.observe(header()!); observer.observe(header()!);
onCleanup(() => observer.disconnect()); onCleanup(() => observer.disconnect());
}); });
return ( return (
<div class={css.container}> <div class={css.container}>
<header ref={setHeader} class={css.header}> <header ref={setHeader} class={css.header}>
<img class={css.background} src={props.entry.image} /> <img class={css.background} src={props.entry.image} />
<h1>{props.entry.title}</h1> <h1>{props.entry.title}</h1>
</header> </header>
</div> </div>
); );
}; };

4
src/components/details/index.ts Normal file → Executable file
View file

@ -1,3 +1,3 @@
export { Details } from './details'; export { Details } from './details';

190
src/components/dropdown/dropdown.module.css Normal file → Executable file
View file

@ -1,96 +1,96 @@
.box { .box {
display: contents; display: contents;
&:has(> :popover-open) > .button { &:has(> :popover-open) > .button {
background-color: var(--surface-500); background-color: var(--surface-500);
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
} }
.button { .button {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: inherit; grid-template-columns: inherit;
place-items: center start; place-items: center start;
/* Make sure the height of the button does not collapse when it is empty */ /* Make sure the height of the button does not collapse when it is empty */
block-size: 1em; block-size: 1em;
box-sizing: content-box; box-sizing: content-box;
padding: var(--size-2); padding: var(--size-2);
background-color: transparent; background-color: transparent;
border: none; border: none;
border-radius: var(--radius-2); border-radius: var(--radius-2);
font-size: 1rem; font-size: 1rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: var(--surface-4); background-color: var(--surface-4);
} }
&:has(> .caret) { &:has(> .caret) {
padding-inline-end: calc(1em + (2 * var(--size-2))); padding-inline-end: calc(1em + (2 * var(--size-2)));
} }
& > .caret { & > .caret {
position: absolute; position: absolute;
inset-inline-end: var(--size-2); inset-inline-end: var(--size-2);
inset-block-start: 50%; inset-block-start: 50%;
translate: 0 -50%; translate: 0 -50%;
inline-size: 1em; inline-size: 1em;
} }
} }
.dialog { .dialog {
display: none; display: none;
position: relative; position: relative;
grid-template-columns: inherit; grid-template-columns: inherit;
inset-inline-start: anchor(start); inset-inline-start: anchor(start);
inset-block-start: anchor(end); inset-block-start: anchor(end);
position-try-fallbacks: flip-block, flip-inline; position-try-fallbacks: flip-block, flip-inline;
/* inline-size: anchor-size(self-inline); */ /* inline-size: anchor-size(self-inline); */
background-color: var(--surface-4); background-color: var(--surface-4);
padding: var(--size-2); padding: var(--size-2);
border: none; border: none;
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
&:popover-open { &:popover-open {
display: grid; display: grid;
} }
& > header { & > header {
display: grid; display: grid;
grid-column: 1 / -1; grid-column: 1 / -1;
gap: var(--size-1); gap: var(--size-1);
} }
& > main { & > main {
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
grid-column: 1 / -1; grid-column: 1 / -1;
row-gap: var(--size-1); row-gap: var(--size-1);
} }
} }
.option { .option {
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
grid-column: 1 / -1; grid-column: 1 / -1;
place-items: center start; place-items: center start;
border-radius: var(--radius-2); border-radius: var(--radius-2);
padding: var(--size-1); padding: var(--size-1);
margin-inline: calc(-1 * var(--size-1)); margin-inline: calc(-1 * var(--size-1));
cursor: pointer; cursor: pointer;
&.selected { &.selected {
background-color: color(from var(--cyan-4) srgb r g b / .1); background-color: color(from var(--cyan-4) srgb r g b / .1);
} }
} }

102
src/components/dropdown/dropdown.tsx Normal file → Executable file
View file

@ -1,52 +1,52 @@
import { createSignal, JSX, createEffect, Show } from "solid-js"; import { createSignal, JSX, createEffect, Show } from "solid-js";
import { FaSolidAngleDown } from "solid-icons/fa"; import { FaSolidAngleDown } from "solid-icons/fa";
import css from './dropdown.module.css'; import css from './dropdown.module.css';
export interface DropdownApi { export interface DropdownApi {
show(): void; show(): void;
hide(): void; hide(): void;
} }
interface DropdownProps { interface DropdownProps {
api?: (api: DropdownApi) => any, api?: (api: DropdownApi) => any,
id: string; id: string;
class?: string; class?: string;
open?: boolean; open?: boolean;
showCaret?: boolean; showCaret?: boolean;
text: JSX.Element; text: JSX.Element;
children: JSX.Element; children: JSX.Element;
} }
export function Dropdown(props: DropdownProps) { export function Dropdown(props: DropdownProps) {
const [dialog, setDialog] = createSignal<HTMLDialogElement>(); const [dialog, setDialog] = createSignal<HTMLDialogElement>();
const [open, setOpen] = createSignal<boolean>(props.open ?? false); const [open, setOpen] = createSignal<boolean>(props.open ?? false);
createEffect(() => { createEffect(() => {
dialog()?.[open() ? 'showPopover' : 'hidePopover'](); dialog()?.[open() ? 'showPopover' : 'hidePopover']();
}); });
createEffect(() => { createEffect(() => {
props.api?.({ props.api?.({
show() { show() {
dialog()?.showPopover(); dialog()?.showPopover();
}, },
hide() { hide() {
dialog()?.hidePopover(); dialog()?.hidePopover();
}, },
}); });
}); });
return <section class={`${css.box} ${props.class}`}> return <section class={`${css.box} ${props.class}`}>
<button id={`${props.id}_button`} popoverTarget={`${props.id}_dialog`} class={css.button}> <button id={`${props.id}_button`} popoverTarget={`${props.id}_dialog`} class={css.button}>
{props.text} {props.text}
<Show when={props.showCaret}> <Show when={props.showCaret}>
<FaSolidAngleDown class={css.caret} /> <FaSolidAngleDown class={css.caret} />
</Show> </Show>
</button> </button>
<dialog ref={setDialog} id={`${props.id}_dialog`} anchor={`${props.id}_button`} popover class={css.dialog} onToggle={e => setOpen(e.newState === 'open')}> <dialog ref={setDialog} id={`${props.id}_dialog`} anchor={`${props.id}_button`} popover class={css.dialog} onToggle={e => setOpen(e.newState === 'open')}>
{props.children} {props.children}
</dialog> </dialog>
</section>; </section>;
} }

6
src/components/dropdown/index.ts Normal file → Executable file
View file

@ -1,4 +1,4 @@
export type { DropdownApi } from './dropdown'; export type { DropdownApi } from './dropdown';
export { Dropdown } from './dropdown'; export { Dropdown } from './dropdown';

348
src/components/hero/hero.module.css Normal file → Executable file
View file

@ -1,174 +1,174 @@
@property --thumb-image { @property --thumb-image {
syntax: "<image>"; syntax: "<image>";
inherits: true; inherits: true;
} }
.container { .container {
isolation: isolate; isolation: isolate;
display: block grid; display: block grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: 100%; grid-auto-columns: 100%;
container-type: inline-size; container-type: inline-size;
overflow: hidden visible; overflow: hidden visible;
scroll-snap-type: inline mandatory; scroll-snap-type: inline mandatory;
overscroll-behavior-inline: contain; overscroll-behavior-inline: contain;
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
scroll-marker-group: after; scroll-marker-group: after;
&::scroll-marker-group { &::scroll-marker-group {
display: block grid; display: block grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: 5em; grid-auto-columns: 5em;
gap: 1rem; gap: 1rem;
justify-content: start; justify-content: start;
padding-inline: var(--size-6); padding-inline: var(--size-6);
inline-size: 100%; inline-size: 100%;
block-size: 8.333333em; block-size: 8.333333em;
z-index: 1; z-index: 1;
} }
} }
.page { .page {
--__i: var(--sibling-index); --__i: var(--sibling-index);
--__c: var(--sibling-count); --__c: var(--sibling-count);
scroll-snap-align: center; scroll-snap-align: center;
position: relative; position: relative;
display: grid; display: grid;
grid: repeat(3, auto) / 15em 1fr; grid: repeat(3, auto) / 15em 1fr;
grid-template-areas: grid-template-areas:
"thumbnail . ." "thumbnail . ."
"thumbnail title cta" "thumbnail title cta"
"thumbnail detail detail" "thumbnail detail detail"
"thumbnail summary summary"; "thumbnail summary summary";
align-content: end; align-content: end;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: var(--size-6); padding: var(--size-6);
block-size: 80vh; block-size: 80vh;
overflow: clip; overflow: clip;
container-type: scroll-state; container-type: scroll-state;
animation: animation:
animate-in linear forwards, animate-in linear forwards,
animate-out linear forwards; animate-out linear forwards;
animation-timeline: view(inline); animation-timeline: view(inline);
animation-range: entry, exit; animation-range: entry, exit;
color: var(--gray-0); color: var(--gray-0);
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
display: block; display: block;
background: linear-gradient(182.5deg, transparent 20%, var(--surface-2) 90%), background: linear-gradient(182.5deg, transparent 20%, var(--surface-2) 90%),
linear-gradient(transparent 50%, #0007 75%); linear-gradient(transparent 50%, #0007 75%);
} }
&::scroll-marker { &::scroll-marker {
display: block; display: block;
content: " "; content: " ";
inline-size: 5rem; inline-size: 5rem;
aspect-ratio: 3 / 5; aspect-ratio: 3 / 5;
background: var(--thumb-image) center / cover no-repeat; background: var(--thumb-image) center / cover no-repeat;
background-color: cornflowerblue; background-color: cornflowerblue;
border-radius: var(--radius-2); border-radius: var(--radius-2);
transform: scale(1); transform: scale(1);
transform-origin: top left; transform-origin: top left;
transition: 0.3s; transition: 0.3s;
} }
&::scroll-marker:target-current { &::scroll-marker:target-current {
/* outline: 1px solid white; */ /* outline: 1px solid white; */
transform: translate(calc(-0cqi - (6rem * (var(--__i) - 1))), -29rem) transform: translate(calc(-0cqi - (6rem * (var(--__i) - 1))), -29rem)
scale(3); scale(3);
} }
} }
.title { .title {
grid-area: title; grid-area: title;
font-size: 2.5em; font-size: 2.5em;
z-index: 1; z-index: 1;
filter: contrast(9); filter: contrast(9);
} }
.cta { .cta {
grid-area: cta; grid-area: cta;
z-index: 1; z-index: 1;
border-radius: var(--radius-2); border-radius: var(--radius-2);
background-color: var(--gray-2); background-color: var(--gray-2);
color: var(--gray-8); color: var(--gray-8);
text-decoration-color: var(--gray-8); text-decoration-color: var(--gray-8);
padding: var(--size-3); padding: var(--size-3);
font-weight: var(--font-weight-9); font-weight: var(--font-weight-9);
outline-offset: var(--size-1); outline-offset: var(--size-1);
&:focus-visible { &:focus-visible {
outline: 1px solid var(--gray-2); outline: 1px solid var(--gray-2);
} }
} }
.thumbnail { .thumbnail {
grid-area: thumbnail; grid-area: thumbnail;
inline-size: 15em; inline-size: 15em;
aspect-ratio: 3 / 5; aspect-ratio: 3 / 5;
border-radius: var(--radius-3); border-radius: var(--radius-3);
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
z-index: 1; z-index: 1;
opacity: 0 !important; opacity: 0 !important;
} }
.background { .background {
position: absolute; position: absolute;
inset: 0; inset: 0;
block-size: 100%; block-size: 100%;
inline-size: 100%; inline-size: 100%;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
z-index: 0; z-index: 0;
} }
.detail { .detail {
grid-area: detail; grid-area: detail;
z-index: 1; z-index: 1;
} }
.summary { .summary {
grid-area: summary; grid-area: summary;
text-wrap: balance; text-wrap: balance;
z-index: 1; z-index: 1;
} }
@keyframes animate-in { @keyframes animate-in {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
@keyframes animate-out { @keyframes animate-out {
0% { 0% {
opacity: 1; opacity: 1;
} }
20% { 20% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 0; opacity: 0;
} }
} }

122
src/components/hero/hero.tsx Normal file → Executable file
View file

@ -1,61 +1,61 @@
import { Component, createEffect, createMemo, For, Index } from "solid-js"; import { Component, createEffect, createMemo, For, Index } from "solid-js";
import { createSlug, Entry } from "~/features/content"; import { createSlug, Entry } from "~/features/content";
import css from "./hero.module.css"; import css from "./hero.module.css";
type HeroProps = { type HeroProps = {
entries: Entry[]; entries: Entry[];
class?: string; class?: string;
}; };
export function Hero(props: HeroProps) { export function Hero(props: HeroProps) {
return ( return (
<div class={`${css.container} ${props.class ?? ""}`}> <div class={`${css.container} ${props.class ?? ""}`}>
<For each={props.entries}>{(entry) => <Page entry={entry} />}</For> <For each={props.entries}>{(entry) => <Page entry={entry} />}</For>
</div> </div>
); );
} }
const Page: Component<{ entry: Entry }> = (props) => { const Page: Component<{ entry: Entry }> = (props) => {
const slug = createMemo(() => createSlug(props.entry)); const slug = createMemo(() => createSlug(props.entry));
return ( return (
<div <div
class={`${css.page}`} class={`${css.page}`}
style={{ "--thumb-image": `url(${props.entry.thumbnail})` }} style={{ "--thumb-image": `url(${props.entry.thumbnail})` }}
> >
<h2 class={css.title}>{props.entry.title}</h2> <h2 class={css.title}>{props.entry.title}</h2>
<a class={css.cta} href={`/play/${slug()}`}> <a class={css.cta} href={`/play/${slug()}`}>
Continue Continue
</a> </a>
<img src={props.entry.thumbnail} class={css.thumbnail} /> <img src={props.entry.thumbnail} class={css.thumbnail} />
{/* <img src={props.entry.image} class={css.background} /> */} {/* <img src={props.entry.image} class={css.background} /> */}
<video <video
class={css.background} class={css.background}
src={props.entry.trailer} src={props.entry.trailer}
poster={props.entry.image} poster={props.entry.image}
muted muted
autoplay autoplay
/> />
<span class={css.detail}> <span class={css.detail}>
{props.entry.releaseDate} {props.entry.releaseDate}
<Index each={props.entry.sources ?? []}> <Index each={props.entry.sources ?? []}>
{(source) => ( {(source) => (
<> <>
&nbsp;&nbsp; &nbsp;&nbsp;
<a href={source().url.toString()} target="_blank"> <a href={source().url.toString()} target="_blank">
{source().rating.score} {source().label} {source().rating.score} {source().label}
</a> </a>
</> </>
)} )}
</Index> </Index>
</span> </span>
<p class={css.summary}>{props.entry.overview}</p> <p class={css.summary}>{props.entry.overview}</p>
</div> </div>
); );
}; };

2
src/components/hero/index.ts Normal file → Executable file
View file

@ -1 +1 @@
export { Hero } from "./hero"; export { Hero } from "./hero";

2
src/components/list/index.ts Normal file → Executable file
View file

@ -1 +1 @@
export { List } from "./list"; export { List } from "./list";

184
src/components/list/list.module.css Normal file → Executable file
View file

@ -1,92 +1,92 @@
.container { .container {
--_space: var(--size-6); --_space: var(--size-6);
display: grid; display: grid;
grid: auto auto / auto auto; grid: auto auto / auto auto;
grid-template-areas: grid-template-areas:
"heading metadata" "heading metadata"
"list list"; "list list";
justify-content: space-between; justify-content: space-between;
inline-size: 100%; inline-size: 100%;
padding-inline: var(--_space); padding-inline: var(--_space);
} }
.heading { .heading {
grid-area: heading; grid-area: heading;
font-size: var(--size-7); font-size: var(--size-7);
color: var(--text-1); color: var(--text-1);
padding-inline: var(--_space); padding-inline: var(--_space);
} }
.metadata { .metadata {
grid-area: metadata; grid-area: metadata;
color: var(--text-2); color: var(--text-2);
} }
.list { .list {
grid-area: list; grid-area: list;
list-style-type: none; list-style-type: none;
container-type: inline-size; container-type: inline-size;
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
gap: var(--_space); gap: var(--_space);
padding: calc(8 * var(--_space)) calc(2 * var(--_space)) calc(2.5 * var(--_space)); padding: calc(8 * var(--_space)) calc(2 * var(--_space)) calc(2.5 * var(--_space));
scroll-padding: calc(2 * var(--_space)); scroll-padding: calc(2 * var(--_space));
margin: calc(-7 * var(--_space)) calc(-1 * var(--_space)) 0em; margin: calc(-7 * var(--_space)) calc(-1 * var(--_space)) 0em;
overflow: visible auto; overflow: visible auto;
scroll-snap-type: inline mandatory; scroll-snap-type: inline mandatory;
overscroll-behavior-inline: contain; overscroll-behavior-inline: contain;
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* the before and afters have unsnappable elements that create bouncy edges to the scroll */ /* the before and afters have unsnappable elements that create bouncy edges to the scroll */
&::before, &::before,
&::after { &::after {
content: ""; content: "";
display: block; display: block;
} }
&::before { &::before {
inline-size: 15cqi; inline-size: 15cqi;
} }
&::after { &::after {
inline-size: 100cqi; inline-size: 100cqi;
} }
& > li { & > li {
scroll-snap-align: start; scroll-snap-align: start;
container-type: scroll-state; container-type: scroll-state;
padding: 0; padding: 0;
position: relative; position: relative;
isolation: isolate; isolation: isolate;
z-index: calc(var(--sibling-count) - var(--sibling-index)); z-index: calc(var(--sibling-count) - var(--sibling-index));
&:has(> :hover, > :focus-within) { &:has(> :hover, > :focus-within) {
z-index: calc(var(--sibling-count) + 1); z-index: calc(var(--sibling-count) + 1);
} }
& > * { & > * {
@supports (animation-timeline: view()) { @supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
animation: slide-in linear both; animation: slide-in linear both;
animation-timeline: view(inline); animation-timeline: view(inline);
animation-range: cover -100cqi contain 15cqi; animation-range: cover -100cqi contain 15cqi;
} }
} }
} }
} }
} }
@keyframes slide-in { @keyframes slide-in {
from { from {
transform: translateX(-100cqi) scale(0.5); transform: translateX(-100cqi) scale(0.5);
} }
} }

54
src/components/list/list.tsx Normal file → Executable file
View file

@ -1,27 +1,27 @@
import { Accessor, Index, JSX } from "solid-js"; import { Accessor, Index, JSX } from "solid-js";
import css from "./list.module.css"; import css from "./list.module.css";
interface ListProps<T> { interface ListProps<T> {
label: string; label: string;
items: T[]; items: T[];
class?: string; class?: string;
children: (item: Accessor<T>) => JSX.Element; children: (item: Accessor<T>) => JSX.Element;
} }
export function List<T>(props: ListProps<T>) { export function List<T>(props: ListProps<T>) {
return ( return (
<section class={`${css.container} ${props.class ?? ""}`}> <section class={`${css.container} ${props.class ?? ""}`}>
<b role="heading" class={css.heading}> <b role="heading" class={css.heading}>
{props.label} {props.label}
</b> </b>
<sub class={css.metadata}>{props.items.length} result(s)</sub> <sub class={css.metadata}>{props.items.length} result(s)</sub>
<ul class={css.list}> <ul class={css.list}>
<Index each={props.items}> <Index each={props.items}>
{(item) => <li>{props.children(item)}</li>} {(item) => <li>{props.children(item)}</li>}
</Index> </Index>
</ul> </ul>
</section> </section>
); );
} }

4
src/components/select/index.ts Normal file → Executable file
View file

@ -1,3 +1,3 @@
export { Select } from './select'; export { Select } from './select';

350
src/components/select/select.module.css Normal file → Executable file
View file

@ -1,176 +1,176 @@
.box { .box {
appearance: none; appearance: none;
display: block grid; display: block grid;
place-items: center start; place-items: center start;
padding: var(--size-2); padding: var(--size-2);
background-color: transparent; background-color: transparent;
border: none; border: none;
border-radius: var(--radius-2); border-radius: var(--radius-2);
font-size: 1rem; font-size: 1rem;
&:hover { &:hover {
background-color: var(--surface-700); background-color: var(--surface-700);
} }
@supports (appearance: base-select) { @supports (appearance: base-select) {
&, &,
&::picker(select) { &::picker(select) {
appearance: base-select; appearance: base-select;
} }
&::picker(select) { &::picker(select) {
/* display: block grid; /* display: block grid;
row-gap: var(--size-2); */ row-gap: var(--size-2); */
background-color: var(--surface-3); background-color: var(--surface-3);
padding: var(--size-2) 0; padding: var(--size-2) 0;
border: none; border: none;
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
opacity: 0; opacity: 0;
block-size: 0; block-size: 0;
overflow: clip; overflow: clip;
transition: transition:
height 0.5s ease-out, height 0.5s ease-out,
opacity 0.5s ease-out, opacity 0.5s ease-out,
overlay 0.5s, overlay 0.5s,
display 0.5s, display 0.5s,
overflow 0.5s; overflow 0.5s;
transition-behavior: allow-discrete; transition-behavior: allow-discrete;
} }
&:open { &:open {
background-color: var(--surface-3); background-color: var(--surface-3);
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
&::picker(select) { &::picker(select) {
opacity: 1; opacity: 1;
block-size: calc-size(auto, size); block-size: calc-size(auto, size);
overflow: auto; overflow: auto;
@starting-style { @starting-style {
opacity: 0; opacity: 0;
block-size: 0; block-size: 0;
} }
} }
} }
& > option { & > option {
display: block grid; display: block grid;
grid-auto-flow: column; grid-auto-flow: column;
place-items: center start; place-items: center start;
border-radius: var(--radius-2); border-radius: var(--radius-2);
padding: var(--size-2); padding: var(--size-2);
cursor: pointer; cursor: pointer;
&:checked { &:checked {
background-color: var(--surface-4); background-color: var(--surface-4);
} }
&::checkmark { &::checkmark {
display: none; display: none;
} }
} }
} }
} }
/* .box { /* .box {
display: contents; display: contents;
&:has(> :popover-open) > .button { &:has(> :popover-open) > .button {
background-color: var(--surface-500); background-color: var(--surface-500);
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
} }
.button { .button {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: inherit; grid-template-columns: inherit;
place-items: center start; place-items: center start;
block-size: 1em; block-size: 1em;
box-sizing: content-box; box-sizing: content-box;
padding: var(--size-2); padding: var(--size-2);
background-color: transparent; background-color: transparent;
border: none; border: none;
border-radius: var(--radius-2); border-radius: var(--radius-2);
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: var(--surface-700); background-color: var(--surface-700);
} }
&:has(> .caret) { &:has(> .caret) {
padding-inline-end: calc(1em + (2 * var(--size-2))); padding-inline-end: calc(1em + (2 * var(--size-2)));
} }
& > .caret { & > .caret {
position: absolute; position: absolute;
inset-inline-end: var(--size-2); inset-inline-end: var(--size-2);
inset-block-start: 50%; inset-block-start: 50%;
translate: 0 -50%; translate: 0 -50%;
inline-size: 1em; inline-size: 1em;
} }
} }
.dialog { .dialog {
display: none; display: none;
position: relative; position: relative;
grid-template-columns: inherit; grid-template-columns: inherit;
inset-inline-start: anchor(start); inset-inline-start: anchor(start);
inset-block-start: anchor(end); inset-block-start: anchor(end);
position-try-fallbacks: flip-block, flip-inline; position-try-fallbacks: flip-block, flip-inline;
background-color: var(--surface-3); background-color: var(--surface-3);
padding: var(--size-2); padding: var(--size-2);
border: none; border: none;
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
&:popover-open { &:popover-open {
display: grid; display: grid;
} }
& > header { & > header {
display: grid; display: grid;
grid-column: 1 / -1; grid-column: 1 / -1;
gap: var(--padding-s); gap: var(--padding-s);
} }
& > main { & > main {
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
grid-column: 1 / -1; grid-column: 1 / -1;
row-gap: var(--padding-s); row-gap: var(--padding-s);
} }
} }
.option { .option {
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
grid-column: 1 / -1; grid-column: 1 / -1;
place-items: center start; place-items: center start;
border-radius: var(--radii-m); border-radius: var(--radii-m);
padding: var(--padding-s); padding: var(--padding-s);
margin-inline: calc(-1 * var(--padding-s)); margin-inline: calc(-1 * var(--padding-s));
cursor: pointer; cursor: pointer;
&.selected { &.selected {
background-color: oklch(from var(--info) l c h / .1); background-color: oklch(from var(--info) l c h / .1);
} }
} */ } */

132
src/components/select/select.tsx Normal file → Executable file
View file

@ -1,67 +1,67 @@
import { createMemo, createSignal, For, JSX, Setter, createEffect, Show } from "solid-js"; import { createMemo, createSignal, For, JSX, Setter, createEffect, Show } from "solid-js";
import { Dropdown, DropdownApi } from "../dropdown"; import { Dropdown, DropdownApi } from "../dropdown";
import css from './select.module.css'; import css from './select.module.css';
interface SelectProps<T, K extends string> { interface SelectProps<T, K extends string> {
id: string; id: string;
class?: string; class?: string;
value: K; value: K;
setValue?: Setter<K>; setValue?: Setter<K>;
values: Record<K, T>; values: Record<K, T>;
open?: boolean; open?: boolean;
showCaret?: boolean; showCaret?: boolean;
children: (key: K, value: T) => JSX.Element; children: (key: K, value: T) => JSX.Element;
filter?: (query: string, key: K, value: T) => boolean; filter?: (query: string, key: K, value: T) => boolean;
} }
export function Select<T, K extends string>(props: SelectProps<T, K>) { export function Select<T, K extends string>(props: SelectProps<T, K>) {
const [dropdown, setDropdown] = createSignal<DropdownApi>(); const [dropdown, setDropdown] = createSignal<DropdownApi>();
const [key, setKey] = createSignal<K>(props.value); const [key, setKey] = createSignal<K>(props.value);
const [query, setQuery] = createSignal<string>(''); const [query, setQuery] = createSignal<string>('');
const showCaret = createMemo(() => props.showCaret ?? true); const showCaret = createMemo(() => props.showCaret ?? true);
const values = createMemo(() => { const values = createMemo(() => {
let entries = Object.entries<T>(props.values) as [K, T][]; let entries = Object.entries<T>(props.values) as [K, T][];
const filter = props.filter; const filter = props.filter;
const q = query(); const q = query();
if (filter) { if (filter) {
entries = entries.filter(([k, v]) => filter(q, k, v)); entries = entries.filter(([k, v]) => filter(q, k, v));
} }
return entries; return entries;
}); });
createEffect(() => { createEffect(() => {
props.setValue?.(() => key()); props.setValue?.(() => key());
}); });
const text = <Show when={key()}>{ const text = <Show when={key()}>{
key => { key => {
const value = createMemo(() => props.values[key()]); const value = createMemo(() => props.values[key()]);
return <>{props.children(key(), value())}</>; return <>{props.children(key(), value())}</>;
} }
}</Show> }</Show>
return <Dropdown api={setDropdown} id={props.id} class={`${css.box} ${props.class}`} showCaret={showCaret()} open={props.open} text={text}> return <Dropdown api={setDropdown} id={props.id} class={`${css.box} ${props.class}`} showCaret={showCaret()} open={props.open} text={text}>
<Show when={props.filter !== undefined}> <Show when={props.filter !== undefined}>
<header> <header>
<input value={query()} onInput={e => setQuery(e.target.value)} /> <input value={query()} onInput={e => setQuery(e.target.value)} />
</header> </header>
</Show> </Show>
<main> <main>
<For each={values()}>{ <For each={values()}>{
([k, v]) => { ([k, v]) => {
const selected = createMemo(() => key() === k); const selected = createMemo(() => key() === k);
return <span class={`${css.option} ${selected() ? css.selected : ''}`} onpointerdown={() => { return <span class={`${css.option} ${selected() ? css.selected : ''}`} onpointerdown={() => {
setKey(() => k); setKey(() => k);
dropdown()?.hide(); dropdown()?.hide();
}}>{props.children(k, v)}</span>; }}>{props.children(k, v)}</span>;
} }
}</For> }</For>
</main> </main>
</Dropdown> </Dropdown>
} }

2
src/features/shell/index.tsx Normal file → Executable file
View file

@ -1 +1 @@
export { Shell } from './shell'; export { Shell } from './shell';

336
src/features/shell/nav.module.css Normal file → Executable file
View file

@ -1,168 +1,168 @@
.nav { .nav {
grid-area: 2 / 1 / 3 / 2; grid-area: 2 / 1 / 3 / 2;
display: block grid; display: block grid;
grid-auto-flow: row; grid-auto-flow: row;
justify-content: space-between; justify-content: space-between;
inline-size: 5em; inline-size: 5em;
block-size: 100%; block-size: 100%;
padding: 1em; padding: 1em;
background: inherit; background: inherit;
z-index: 0; z-index: 0;
transition: z-index 0.3s step-end; transition: z-index 0.3s step-end;
& > ul { & > ul {
position: relative; position: relative;
display: block grid; display: block grid;
grid-template-columns: 2.5rem auto; grid-template-columns: 2.5rem auto;
align-content: center; align-content: center;
inline-size: 4rem; inline-size: 4rem;
gap: 1rem; gap: 1rem;
transform-origin: left center; transform-origin: left center;
padding: 0; padding: 0;
padding-inline-start: 0.5rem; padding-inline-start: 0.5rem;
margin: 0; margin: 0;
&::before { &::before {
content: ""; content: "";
position: absolute; position: absolute;
inset-inline-start: 100%; inset-inline-start: 100%;
inset-block: -1em; inset-block: -1em;
inline-size: 40vw; inline-size: 40vw;
background-image: linear-gradient(to right, rgb(from var(--surface-1) r g b / .9) 50%, transparent); background-image: linear-gradient(to right, rgb(from var(--surface-1) r g b / .9) 50%, transparent);
mask: radial-gradient( mask: radial-gradient(
ellipse 40vw 100% at left center, ellipse 40vw 100% at left center,
black 25%, black 25%,
transparent transparent
); );
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
opacity: 0; opacity: 0;
transition: opacity 0.3s var(--ease-3); transition: opacity 0.3s var(--ease-3);
} }
& > a { & > a {
position: relative; position: relative;
grid-column: span 2; grid-column: span 2;
display: block grid; display: block grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
transform-origin: center left; transform-origin: center left;
transition: transition:
transform 2s var(--ease-spring-5), transform 2s var(--ease-spring-5),
opacity 0.3s var(--ease-3); opacity 0.3s var(--ease-3);
color: var(--text-2); color: var(--text-2);
font-size: 2rem; font-size: 2rem;
line-height: 1.5; line-height: 1.5;
& > span { & > span {
opacity: 0; opacity: 0;
transition: opacity 0.3s var(--ease-3); transition: opacity 0.3s var(--ease-3);
text-shadow: 0 0 .5em var(--surface-1); text-shadow: 0 0 .5em var(--surface-1);
} }
& > svg { & > svg {
fill: var(--text-2); fill: var(--text-2);
inline-size: 2.5rem; inline-size: 2.5rem;
block-size: 2.5rem; block-size: 2.5rem;
} }
&.active { &.active {
color: var(--yellow-5); color: var(--yellow-5);
list-style: disc; list-style: disc;
&::before { &::before {
content: "•"; content: "•";
position: absolute; position: absolute;
inset-inline-start: -1rem; inset-inline-start: -1rem;
} }
& > svg { & > svg {
fill: var(--yellow-5); fill: var(--yellow-5);
} }
} }
} }
&:has(a:is(:hover, :focus)) { &:has(a:is(:hover, :focus)) {
&::before { &::before {
opacity: 1; opacity: 1;
} }
& > a { & > a {
transform: scale(max(1, calc(1.5 - (0.2 * abs(var(--target) - var(--sibling-index)))))); transform: scale(max(1, calc(1.5 - (0.2 * abs(var(--target) - var(--sibling-index))))));
& > span { & > span {
opacity: 1; opacity: 1;
} }
} }
} }
&:has(a:is(:hover, :focus)) > a:not(:is(:hover, :focus)) { &:has(a:is(:hover, :focus)) > a:not(:is(:hover, :focus)) {
opacity: 0.25; opacity: 0.25;
} }
&:has(a:is(:hover, :focus):nth-child(1)) { &:has(a:is(:hover, :focus):nth-child(1)) {
--target: 1; --target: 1;
} }
&:has(a:is(:hover, :focus):nth-child(2)) { &:has(a:is(:hover, :focus):nth-child(2)) {
--target: 2; --target: 2;
} }
&:has(a:is(:hover, :focus):nth-child(3)) { &:has(a:is(:hover, :focus):nth-child(3)) {
--target: 3; --target: 3;
} }
&:has(a:is(:hover, :focus):nth-child(4)) { &:has(a:is(:hover, :focus):nth-child(4)) {
--target: 4; --target: 4;
} }
&:has(a:is(:hover, :focus):nth-child(5)) { &:has(a:is(:hover, :focus):nth-child(5)) {
--target: 5; --target: 5;
} }
&:has(a:is(:hover, :focus):nth-child(6)) { &:has(a:is(:hover, :focus):nth-child(6)) {
--target: 6; --target: 6;
} }
&:has(a:is(:hover, :focus):nth-child(7)) { &:has(a:is(:hover, :focus):nth-child(7)) {
--target: 7; --target: 7;
} }
&:has(a:is(:hover, :focus):nth-child(8)) { &:has(a:is(:hover, :focus):nth-child(8)) {
--target: 8; --target: 8;
} }
&:has(a:is(:hover, :focus):nth-child(9)) { &:has(a:is(:hover, :focus):nth-child(9)) {
--target: 9; --target: 9;
} }
&:has(a:is(:hover, :focus):nth-child(10)) { &:has(a:is(:hover, :focus):nth-child(10)) {
--target: 10; --target: 10;
} }
&:has(a:is(:hover, :focus):nth-child(11)) { &:has(a:is(:hover, :focus):nth-child(11)) {
--target: 11; --target: 11;
} }
&:has(a:is(:hover, :focus):nth-child(12)) { &:has(a:is(:hover, :focus):nth-child(12)) {
--target: 12; --target: 12;
} }
&:has(a:is(:hover, :focus):nth-child(13)) { &:has(a:is(:hover, :focus):nth-child(13)) {
--target: 13; --target: 13;
} }
&:has(a:is(:hover, :focus):nth-child(14)) { &:has(a:is(:hover, :focus):nth-child(14)) {
--target: 14; --target: 14;
} }
&:has(a:is(:hover, :focus):nth-child(15)) { &:has(a:is(:hover, :focus):nth-child(15)) {
--target: 15; --target: 15;
} }
} }
&:has(a:hover, :focus-within) { &:has(a:hover, :focus-within) {
z-index: 1; z-index: 1;
transition: z-index 0.3s step-start; transition: z-index 0.3s step-start;
} }
} }

50
src/features/shell/nav.tsx Normal file → Executable file
View file

@ -1,25 +1,25 @@
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { AiOutlineHome, AiOutlineStar, AiOutlineSearch } from "solid-icons/ai"; import { AiOutlineHome, AiOutlineStar, AiOutlineSearch } from "solid-icons/ai";
import { Component } from "solid-js"; import { Component } from "solid-js";
import css from "./nav.module.css"; import css from "./nav.module.css";
export const Nav: Component = (props) => { export const Nav: Component = (props) => {
return ( return (
<nav class={css.nav}> <nav class={css.nav}>
<ul> <ul>
<A href="/" activeClass={css.active} end={true}> <A href="/" activeClass={css.active} end={true}>
<AiOutlineHome /> <AiOutlineHome />
<span>Home</span> <span>Home</span>
</A> </A>
<A href="/library" activeClass={css.active}> <A href="/library" activeClass={css.active}>
<AiOutlineStar /> <AiOutlineStar />
<span>Library</span> <span>Library</span>
</A> </A>
<A href="/search" activeClass={css.active}> <A href="/search" activeClass={css.active}>
<AiOutlineSearch /> <AiOutlineSearch />
<span>Search</span> <span>Search</span>
</A> </A>
</ul> </ul>
</nav> </nav>
); );
}; };

102
src/features/shell/shell.module.css Normal file → Executable file
View file

@ -1,52 +1,52 @@
.container { .container {
position: relative; position: relative;
display: block grid; display: block grid;
grid: auto 1fr / 5em 1fr; grid: auto 1fr / 5em 1fr;
grid-template-areas: grid-template-areas:
"top top" "top top"
"nav content"; "nav content";
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
z-index: 0; z-index: 0;
overflow: clip; overflow: clip;
container-type: inline-size; container-type: inline-size;
background-color: var(--surface-1); background-color: var(--surface-1);
contain: layout style paint; contain: layout style paint;
&::after { &::after {
content: ''; content: '';
grid-area: content; grid-area: content;
display: block; display: block;
position: absolute; position: absolute;
inset-inline-start: 0; inset-inline-start: 0;
inset-block-start: 0; inset-block-start: 0;
inline-size: var(--radius-4); inline-size: var(--radius-4);
block-size: var(--radius-4); block-size: var(--radius-4);
background: radial-gradient(circle at bottom right, transparent var(--radius-4), var(--surface-1) var(--radius-4)); background: radial-gradient(circle at bottom right, transparent var(--radius-4), var(--surface-1) var(--radius-4));
pointer-events: none; pointer-events: none;
} }
} }
.body { .body {
grid-area: 2 / 1 / 3 / 3; grid-area: 2 / 1 / 3 / 3;
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
background: linear-gradient(180deg, background: linear-gradient(180deg,
transparent, transparent,
transparent 90vh, transparent 90vh,
var(--surface-500) 90vh, var(--surface-500) 90vh,
var(--surface-500)); var(--surface-500));
overflow: clip auto; overflow: clip auto;
padding-inline-start: 5em; padding-inline-start: 5em;
transition: filter var(--duration-moderate-1) var(--ease-3); transition: filter var(--duration-moderate-1) var(--ease-3);
container-type: size; container-type: size;
& > div { & > div {
background-color: var(--surface-2); background-color: var(--surface-2);
container-type: inline-size; container-type: inline-size;
contain: layout style paint; contain: layout style paint;
inline-size: 100%; inline-size: 100%;
block-size: fit-content; block-size: fit-content;
min-block-size: 100%; min-block-size: 100%;
} }
} }

44
src/features/shell/shell.tsx Normal file → Executable file
View file

@ -1,22 +1,22 @@
import { ParentComponent } from "solid-js"; import { ParentComponent } from "solid-js";
import { Top } from "./top"; import { Top } from "./top";
import { Nav } from "./nav"; import { Nav } from "./nav";
import css from "./shell.module.css"; import css from "./shell.module.css";
import { User } from "../user"; import { User } from "../user";
interface ShellProps { interface ShellProps {
user: User | undefined; user: User | undefined;
} }
export const Shell: ParentComponent<ShellProps> = (props) => { export const Shell: ParentComponent<ShellProps> = (props) => {
return ( return (
<main class={css.container}> <main class={css.container}>
<Top user={props.user} /> <Top user={props.user} />
<Nav /> <Nav />
<div class={css.body}> <div class={css.body}>
<div>{props.children}</div> <div>{props.children}</div>
</div> </div>
</main> </main>
); );
}; };

74
src/features/shell/top.module.css Normal file → Executable file
View file

@ -1,37 +1,37 @@
.top { .top {
grid-area: 1 / 1 / 2 / 3; grid-area: 1 / 1 / 2 / 3;
display: block grid; display: block grid;
grid-auto-flow: column; grid-auto-flow: column;
justify-content: end; justify-content: end;
z-index: 1; z-index: 1;
background-color: inherit; background-color: inherit;
padding: 0.5em; padding: 0.5em;
} }
.accountTrigger { .accountTrigger {
anchor-name: --account-trigger; anchor-name: --account-trigger;
background: transparent; background: transparent;
padding: 0; padding: 0;
margin: 0; margin: 0;
border-radius: var(--radius-round); border-radius: var(--radius-round);
} }
.accountMenu { .accountMenu {
position-anchor: --account-trigger; position-anchor: --account-trigger;
position: absolute; position: absolute;
inset: auto; inset: auto;
inset-inline-end: anchor(end); inset-inline-end: anchor(end);
inset-block-start: anchor(start); inset-block-start: anchor(start);
display: block grid; display: block grid;
grid-auto-flow: row; grid-auto-flow: row;
gap: var(--size-3); gap: var(--size-3);
padding: var(--size-3); padding: var(--size-3);
background-color: light-dark(var(--gray-1), var(--gray-9)); background-color: light-dark(var(--gray-1), var(--gray-9));
border-radius: var(--radius-2); border-radius: var(--radius-2);
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
&:not(:popover-open) { &:not(:popover-open) {
display: none; display: none;
} }
} }

118
src/features/shell/top.tsx Normal file → Executable file
View file

@ -1,59 +1,59 @@
import { Component, Show } from "solid-js"; import { Component, Show } from "solid-js";
import { signIn, signOut, client } from "~/auth.client"; import { signIn, signOut, client } from "~/auth.client";
import { Avatar, Profile, User } from "../user"; import { Avatar, Profile, User } from "../user";
import { ColorSchemePicker } from "../theme"; import { ColorSchemePicker } from "../theme";
import css from "./top.module.css"; import css from "./top.module.css";
interface TopProps { interface TopProps {
user: User | undefined; user: User | undefined;
} }
export const Top: Component<TopProps> = (props) => { export const Top: Component<TopProps> = (props) => {
const login = async (e: SubmitEvent) => { const login = async (e: SubmitEvent) => {
e.preventDefault(); e.preventDefault();
await signIn.oauth2({ await signIn.oauth2({
providerId: "authelia", providerId: "authelia",
callbackURL: "/", callbackURL: "/",
}); });
}; };
const logout = async (e: SubmitEvent) => { const logout = async (e: SubmitEvent) => {
e.preventDefault(); e.preventDefault();
await signOut(); await signOut();
}; };
return ( return (
<aside class={css.top}> <aside class={css.top}>
<Show <Show
when={props.user} when={props.user}
fallback={ fallback={
<form method="post" onSubmit={login}> <form method="post" onSubmit={login}>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form> </form>
} }
> >
{(user) => ( {(user) => (
<> <>
<button <button
class={css.accountTrigger} class={css.accountTrigger}
id="account-menu-trigger" id="account-menu-trigger"
popovertarget="account-menu-popover" popovertarget="account-menu-popover"
> >
<Avatar user={user()} /> <Avatar user={user()} />
</button> </button>
<div class={css.accountMenu} id="account-menu-popover" popover> <div class={css.accountMenu} id="account-menu-popover" popover>
<Profile user={user()} /> <Profile user={user()} />
<a href="/settings">Settings</a> <a href="/settings">Settings</a>
<form method="post" onSubmit={logout}> <form method="post" onSubmit={logout}>
<button type="submit">Log out</button> <button type="submit">Log out</button>
</form> </form>
</div> </div>
</> </>
)} )}
</Show> </Show>
<ColorSchemePicker /> <ColorSchemePicker />
</aside> </aside>
); );
}; };

0
src/features/theme/context.ts Normal file → Executable file
View file

0
src/features/theme/index.ts Normal file → Executable file
View file

0
src/features/theme/picker.module.css Normal file → Executable file
View file

0
src/features/theme/picker.tsx Normal file → Executable file
View file

14
src/features/user/avatar.module.css Normal file → Executable file
View file

@ -1,7 +1,7 @@
.avatar { .avatar {
inline-size: var(--size-8); inline-size: var(--size-8);
border-radius: var(--radius-round); border-radius: var(--radius-round);
aspect-ratio: 1; aspect-ratio: 1;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
} }

54
src/features/user/avatar.tsx Normal file → Executable file
View file

@ -1,27 +1,27 @@
import { Component, createMemo, Show } from "solid-js"; import { Component, createMemo, Show } from "solid-js";
import { User } from "./user"; import { User } from "./user";
import { hash } from "~/utilities"; import { hash } from "~/utilities";
import css from "./avatar.module.css"; import css from "./avatar.module.css";
interface AvatarProps { interface AvatarProps {
user: User | undefined; user: User | undefined;
} }
export const Avatar: Component<AvatarProps> = (props) => { export const Avatar: Component<AvatarProps> = (props) => {
const hashedEmail = hash("SHA-256", () => props.user?.email); const hashedEmail = hash("SHA-256", () => props.user?.email);
const src = createMemo(() => { const src = createMemo(() => {
const user = props.user; const user = props.user;
if (user === undefined) { if (user === undefined) {
return ""; return "";
} }
if (user.image === null) { if (user.image === null) {
return `https://www.gravatar.com/avatar/${hashedEmail()}`; return `https://www.gravatar.com/avatar/${hashedEmail()}`;
} }
return user.image; return user.image;
}); });
return <img src={src()} class={css.avatar} />; return <img src={src()} class={css.avatar} />;
}; };

8
src/features/user/index.ts Normal file → Executable file
View file

@ -1,4 +1,4 @@
export type { User } from "./user"; export type { User } from "./user";
export { Avatar } from "./avatar"; export { Avatar } from "./avatar";
export { Profile } from "./profile"; export { Profile } from "./profile";

44
src/features/user/profile.module.css Normal file → Executable file
View file

@ -1,22 +1,22 @@
.profile { .profile {
display: block grid; display: block grid;
grid: auto 1fr / auto 1fr; grid: auto 1fr / auto 1fr;
gap: var(--size-2); gap: var(--size-2);
place-content: start; place-content: start;
background-color: light-dark(var(--gray-1), var(--gray-9)); background-color: light-dark(var(--gray-1), var(--gray-9));
& > img { & > img {
grid-area: span 2 / 1; grid-area: span 2 / 1;
} }
& > strong { & > strong {
font-size: var(--size-4); font-size: var(--size-4);
line-height: 1; line-height: 1;
color: light-dark(var(--gray-7), var(--gray-3)); color: light-dark(var(--gray-7), var(--gray-3));
} }
& > span { & > span {
line-height: 1; line-height: 1;
color: light-dark(var(--gray-4), var(--gray-6)); color: light-dark(var(--gray-4), var(--gray-6));
} }
} }

36
src/features/user/profile.tsx Normal file → Executable file
View file

@ -1,18 +1,18 @@
import { Component } from "solid-js"; import { Component } from "solid-js";
import { User } from "./user"; import { User } from "./user";
import { Avatar } from "./avatar"; import { Avatar } from "./avatar";
import css from "./profile.module.css"; import css from "./profile.module.css";
interface ProfileProps { interface ProfileProps {
user: User | undefined; user: User | undefined;
} }
export const Profile: Component<ProfileProps> = (props) => { export const Profile: Component<ProfileProps> = (props) => {
return ( return (
<div class={css.profile}> <div class={css.profile}>
<Avatar user={props.user} /> <Avatar user={props.user} />
<strong>{props.user?.name ?? ""}</strong> <strong>{props.user?.name ?? ""}</strong>
<span>{props.user?.email ?? ""}</span> <span>{props.user?.email ?? ""}</span>
</div> </div>
); );
}; };

12
src/features/user/user.ts Normal file → Executable file
View file

@ -1,6 +1,6 @@
export interface User { export interface User {
username: string; username: string;
name: string; name: string;
email: string; email: string;
image: string | null; image: string | null;
} }

0
src/routes/(shell).tsx Normal file → Executable file
View file

138
src/utilities.ts Normal file → Executable file
View file

@ -1,69 +1,69 @@
import { Accessor, createEffect, createSignal, on } from "solid-js"; import { Accessor, createEffect, createSignal, on } from "solid-js";
export const splitAt = ( export const splitAt = (
subject: string, subject: string,
index: number, index: number,
): readonly [string, string] => { ): readonly [string, string] => {
if (index < 0) { if (index < 0) {
return [subject, ""]; return [subject, ""];
} }
if (index > subject.length) { if (index > subject.length) {
return [subject, ""]; return [subject, ""];
} }
return [subject.slice(0, index), subject.slice(index + 1)]; return [subject.slice(0, index), subject.slice(index + 1)];
}; };
export const toSlug = (subject: string) => export const toSlug = (subject: string) =>
subject.toLowerCase().replaceAll(" ", "-").replaceAll(/[^\w-]/gi, ""); subject.toLowerCase().replaceAll(" ", "-").replaceAll(/[^\w-]/gi, "");
export const toHex = (subject: number) => subject.toString(16).padStart(2, "0"); export const toHex = (subject: number) => subject.toString(16).padStart(2, "0");
const encoder = new TextEncoder(); const encoder = new TextEncoder();
export const hash = ( export const hash = (
algorithm: AlgorithmIdentifier, algorithm: AlgorithmIdentifier,
subject: Accessor<string | null | undefined>, subject: Accessor<string | null | undefined>,
) => { ) => {
const [hash, setHash] = createSignal<string>(); const [hash, setHash] = createSignal<string>();
createEffect( createEffect(
on(subject, async (subject) => { on(subject, async (subject) => {
if (subject === null || subject === undefined || subject.length === 0) { if (subject === null || subject === undefined || subject.length === 0) {
setHash(undefined); setHash(undefined);
return; return;
} }
const buffer = new Uint8Array( const buffer = new Uint8Array(
await crypto.subtle.digest(algorithm, encoder.encode(subject)), await crypto.subtle.digest(algorithm, encoder.encode(subject)),
); );
setHash(Array.from(buffer).map(toHex).join("")); setHash(Array.from(buffer).map(toHex).join(""));
}), }),
); );
return hash; return hash;
}; };
export const merge = (...objects: Record<string, any>[]): Record<string, any> => { export const merge = (...objects: Record<string, any>[]): Record<string, any> => {
if (objects.length === 0) { if (objects.length === 0) {
return {}; return {};
} }
const target = objects[0]; const target = objects[0];
for (const key of new Set(objects.map(o => Object.keys(o)).flat())) { for (const key of new Set(objects.map(o => Object.keys(o)).flat())) {
const values = objects.filter(o => Object.hasOwn(o, key)).map(o => o[key]); const values = objects.filter(o => Object.hasOwn(o, key)).map(o => o[key]);
target[key] = values.every(v => v && typeof v === 'object' && !Array.isArray(v)) ? merge(...values) : values.at(-1); target[key] = values.every(v => v && typeof v === 'object' && !Array.isArray(v)) ? merge(...values) : values.at(-1);
} }
return target; return target;
}; };
type CamelCase<S extends string> = S extends `${infer First}${infer Rest}` ? `${Lowercase<First>}${Rest}` : Lowercase<S>; type CamelCase<S extends string> = S extends `${infer First}${infer Rest}` ? `${Lowercase<First>}${Rest}` : Lowercase<S>;
export type CamelCased<T extends Record<string, any>> = { export type CamelCased<T extends Record<string, any>> = {
[ K in keyof T as CamelCase<string&K>]: T[K]; [ K in keyof T as CamelCase<string&K>]: T[K];
} & {}; } & {};
export const mapKeysToCamelCase = <T extends Record<string, any>>(subject: T): CamelCased<T> => Object.fromEntries(Object.entries(subject).map(([k, v]) => [`${k[0].toLowerCase()}${k.slice(1)}`, v])) as CamelCased<T>; export const mapKeysToCamelCase = <T extends Record<string, any>>(subject: T): CamelCased<T> => Object.fromEntries(Object.entries(subject).map(([k, v]) => [`${k[0].toLowerCase()}${k.slice(1)}`, v])) as CamelCased<T>;