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
All checks were successful
Test action / Print hello world (push) Successful in 6m12s
This commit is contained in:
parent
a502a50176
commit
ff31c28d38
46 changed files with 1403 additions and 1397 deletions
0
bun.lock
Normal file → Executable file
0
bun.lock
Normal file → Executable file
12
bunfig.toml
Normal file → Executable file
12
bunfig.toml
Normal file → Executable 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
0
flake.nix
Normal file → Executable file
6
justfile
Executable file
6
justfile
Executable 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
0
nix/devShells/flake-module.nix
Normal file → Executable file
2
nix/modules/customer-portal/default.nix
Normal file → Executable file
2
nix/modules/customer-portal/default.nix
Normal file → Executable 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
0
nix/modules/customer-portal/flake-module.nix
Normal file → Executable file
0
nix/modules/flake-module.nix
Normal file → Executable file
0
nix/modules/flake-module.nix
Normal file → Executable file
0
nix/packages/flake-module.nix
Normal file → Executable file
0
nix/packages/flake-module.nix
Normal file → Executable file
0
src/app.css
Normal file → Executable file
0
src/app.css
Normal file → Executable file
10
src/auth.client.ts
Normal file → Executable file
10
src/auth.client.ts
Normal file → Executable 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
102
src/auth.server.ts
Normal file → Executable 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
66
src/components/details/details.module.css
Normal file → Executable 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
70
src/components/details/details.tsx
Normal file → Executable 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
4
src/components/details/index.ts
Normal file → Executable file
|
|
@ -1,3 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
export { Details } from './details';
|
export { Details } from './details';
|
||||||
190
src/components/dropdown/dropdown.module.css
Normal file → Executable file
190
src/components/dropdown/dropdown.module.css
Normal file → Executable 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
102
src/components/dropdown/dropdown.tsx
Normal file → Executable 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
6
src/components/dropdown/index.ts
Normal file → Executable 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
348
src/components/hero/hero.module.css
Normal file → Executable 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
122
src/components/hero/hero.tsx
Normal file → Executable 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) => (
|
||||||
<>
|
<>
|
||||||
•
|
•
|
||||||
<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
2
src/components/hero/index.ts
Normal file → Executable file
|
|
@ -1 +1 @@
|
||||||
export { Hero } from "./hero";
|
export { Hero } from "./hero";
|
||||||
|
|
|
||||||
2
src/components/list/index.ts
Normal file → Executable file
2
src/components/list/index.ts
Normal file → Executable file
|
|
@ -1 +1 @@
|
||||||
export { List } from "./list";
|
export { List } from "./list";
|
||||||
|
|
|
||||||
184
src/components/list/list.module.css
Normal file → Executable file
184
src/components/list/list.module.css
Normal file → Executable 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
54
src/components/list/list.tsx
Normal file → Executable 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
4
src/components/select/index.ts
Normal file → Executable file
|
|
@ -1,3 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
export { Select } from './select';
|
export { Select } from './select';
|
||||||
350
src/components/select/select.module.css
Normal file → Executable file
350
src/components/select/select.module.css
Normal file → Executable 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
132
src/components/select/select.tsx
Normal file → Executable 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
2
src/features/shell/index.tsx
Normal file → Executable file
|
|
@ -1 +1 @@
|
||||||
export { Shell } from './shell';
|
export { Shell } from './shell';
|
||||||
|
|
|
||||||
336
src/features/shell/nav.module.css
Normal file → Executable file
336
src/features/shell/nav.module.css
Normal file → Executable 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
50
src/features/shell/nav.tsx
Normal file → Executable 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
102
src/features/shell/shell.module.css
Normal file → Executable 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
44
src/features/shell/shell.tsx
Normal file → Executable 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
74
src/features/shell/top.module.css
Normal file → Executable 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
118
src/features/shell/top.tsx
Normal file → Executable 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
0
src/features/theme/context.ts
Normal file → Executable file
0
src/features/theme/index.ts
Normal file → Executable file
0
src/features/theme/index.ts
Normal file → Executable file
0
src/features/theme/picker.module.css
Normal file → Executable file
0
src/features/theme/picker.module.css
Normal file → Executable file
0
src/features/theme/picker.tsx
Normal file → Executable file
0
src/features/theme/picker.tsx
Normal file → Executable file
14
src/features/user/avatar.module.css
Normal file → Executable file
14
src/features/user/avatar.module.css
Normal file → Executable 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
54
src/features/user/avatar.tsx
Normal file → Executable 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
8
src/features/user/index.ts
Normal file → Executable 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
44
src/features/user/profile.module.css
Normal file → Executable 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
36
src/features/user/profile.tsx
Normal file → Executable 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
12
src/features/user/user.ts
Normal file → Executable 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
0
src/routes/(shell).tsx
Normal file → Executable file
138
src/utilities.ts
Normal file → Executable file
138
src/utilities.ts
Normal file → Executable 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>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue