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]
|
||||
coverage = true
|
||||
coverageSkipTestFiles = true
|
||||
coverageReporter = ['text', 'lcov']
|
||||
coverageDir = './.coverage'
|
||||
preload = "./test.config.ts"
|
||||
[test]
|
||||
coverage = true
|
||||
coverageSkipTestFiles = true
|
||||
coverageReporter = ['text', 'lcov']
|
||||
coverageDir = './.coverage'
|
||||
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 {
|
||||
systemd = {
|
||||
servces.amarthCustomerPortal = {
|
||||
services.amarthCustomerPortal = {
|
||||
after = [ "network.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 { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
export const { signIn, signOut, useSession, ...client } = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
import { createAuthClient } from "better-auth/solid";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
export const { signIn, signOut, useSession, ...client } = createAuthClient({
|
||||
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 { genericOAuth } from "better-auth/plugins";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Streamarr",
|
||||
basePath: "/api/auth",
|
||||
database: new Database('auth.sqlite', { create: true }),
|
||||
logger: {
|
||||
level: "debug",
|
||||
log(level, message, ...args) {
|
||||
console.log(level, message, {args});
|
||||
},
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
name: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "zitadel",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
discoveryUrl: "https://auth.amarth.cloud/.well-known/openid-configuration",
|
||||
scopes: [
|
||||
"offline_access",
|
||||
"openid",
|
||||
"email",
|
||||
"picture",
|
||||
"profile",
|
||||
"groups",
|
||||
],
|
||||
accessType: "offline",
|
||||
pkce: true,
|
||||
mapProfileToUser: ({ id, name, email, image, preferred_username, emailVerified }) =>
|
||||
({ id, name, email, emailVerified, image, username: preferred_username }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
import { betterAuth } from "better-auth";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Streamarr",
|
||||
basePath: "/api/auth",
|
||||
database: new Database('auth.sqlite', { create: true }),
|
||||
logger: {
|
||||
level: "debug",
|
||||
log(level, message, ...args) {
|
||||
console.log(level, message, {args});
|
||||
},
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
name: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "zitadel",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
discoveryUrl: "https://auth.amarth.cloud/.well-known/openid-configuration",
|
||||
scopes: [
|
||||
"offline_access",
|
||||
"openid",
|
||||
"email",
|
||||
"picture",
|
||||
"profile",
|
||||
"groups",
|
||||
],
|
||||
accessType: "offline",
|
||||
pkce: true,
|
||||
mapProfileToUser: ({ id, name, email, image, preferred_username, emailVerified }) =>
|
||||
({ 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 {
|
||||
isolation: isolate;
|
||||
display: block grid;
|
||||
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
block-size: 80cqb;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
atan(var(--ratio, .2)),
|
||||
var(--surface-2) 20em,
|
||||
transparent 90%
|
||||
);
|
||||
}
|
||||
|
||||
& > .background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
.container {
|
||||
isolation: isolate;
|
||||
display: block grid;
|
||||
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
block-size: 80cqb;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
atan(var(--ratio, .2)),
|
||||
var(--surface-2) 20em,
|
||||
transparent 90%
|
||||
);
|
||||
}
|
||||
|
||||
& > .background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
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 { Entry } from "~/features/content";
|
||||
import css from "./details.module.css";
|
||||
|
||||
interface DetailsProps {
|
||||
entry: Entry;
|
||||
}
|
||||
|
||||
export const Details: Component<DetailsProps> = (props) => {
|
||||
const [header, setHeader] = createSignal<HTMLElement>();
|
||||
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { inlineSize, blockSize } = entry.contentBoxSize[0];
|
||||
(entry.target as HTMLElement).style.setProperty(
|
||||
"--ratio",
|
||||
String((blockSize * 0.2) / inlineSize)
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe(header()!);
|
||||
|
||||
onCleanup(() => observer.disconnect());
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={css.container}>
|
||||
<header ref={setHeader} class={css.header}>
|
||||
<img class={css.background} src={props.entry.image} />
|
||||
|
||||
<h1>{props.entry.title}</h1>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Component, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { Entry } from "~/features/content";
|
||||
import css from "./details.module.css";
|
||||
|
||||
interface DetailsProps {
|
||||
entry: Entry;
|
||||
}
|
||||
|
||||
export const Details: Component<DetailsProps> = (props) => {
|
||||
const [header, setHeader] = createSignal<HTMLElement>();
|
||||
|
||||
onMount(() => {
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { inlineSize, blockSize } = entry.contentBoxSize[0];
|
||||
(entry.target as HTMLElement).style.setProperty(
|
||||
"--ratio",
|
||||
String((blockSize * 0.2) / inlineSize)
|
||||
);
|
||||
});
|
||||
|
||||
observer.observe(header()!);
|
||||
|
||||
onCleanup(() => observer.disconnect());
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={css.container}>
|
||||
<header ref={setHeader} class={css.header}>
|
||||
<img class={css.background} src={props.entry.image} />
|
||||
|
||||
<h1>{props.entry.title}</h1>
|
||||
</header>
|
||||
</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';
|
||||
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 {
|
||||
display: contents;
|
||||
|
||||
&:has(> :popover-open) > .button {
|
||||
background-color: var(--surface-500);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: inherit;
|
||||
place-items: center start;
|
||||
|
||||
/* Make sure the height of the button does not collapse when it is empty */
|
||||
block-size: 1em;
|
||||
box-sizing: content-box;
|
||||
|
||||
padding: var(--size-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-4);
|
||||
}
|
||||
|
||||
&:has(> .caret) {
|
||||
padding-inline-end: calc(1em + (2 * var(--size-2)));
|
||||
}
|
||||
|
||||
& > .caret {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--size-2);
|
||||
inset-block-start: 50%;
|
||||
translate: 0 -50%;
|
||||
inline-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: none;
|
||||
position: relative;
|
||||
grid-template-columns: inherit;
|
||||
|
||||
inset-inline-start: anchor(start);
|
||||
inset-block-start: anchor(end);
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
|
||||
/* inline-size: anchor-size(self-inline); */
|
||||
background-color: var(--surface-4);
|
||||
padding: var(--size-2);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
&:popover-open {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
& > header {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
gap: var(--size-1);
|
||||
}
|
||||
|
||||
& > main {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
row-gap: var(--size-1);
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
place-items: center start;
|
||||
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--size-1);
|
||||
margin-inline: calc(-1 * var(--size-1));
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background-color: color(from var(--cyan-4) srgb r g b / .1);
|
||||
}
|
||||
.box {
|
||||
display: contents;
|
||||
|
||||
&:has(> :popover-open) > .button {
|
||||
background-color: var(--surface-500);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: inherit;
|
||||
place-items: center start;
|
||||
|
||||
/* Make sure the height of the button does not collapse when it is empty */
|
||||
block-size: 1em;
|
||||
box-sizing: content-box;
|
||||
|
||||
padding: var(--size-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-4);
|
||||
}
|
||||
|
||||
&:has(> .caret) {
|
||||
padding-inline-end: calc(1em + (2 * var(--size-2)));
|
||||
}
|
||||
|
||||
& > .caret {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--size-2);
|
||||
inset-block-start: 50%;
|
||||
translate: 0 -50%;
|
||||
inline-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: none;
|
||||
position: relative;
|
||||
grid-template-columns: inherit;
|
||||
|
||||
inset-inline-start: anchor(start);
|
||||
inset-block-start: anchor(end);
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
|
||||
/* inline-size: anchor-size(self-inline); */
|
||||
background-color: var(--surface-4);
|
||||
padding: var(--size-2);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
&:popover-open {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
& > header {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
gap: var(--size-1);
|
||||
}
|
||||
|
||||
& > main {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
row-gap: var(--size-1);
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
place-items: center start;
|
||||
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--size-1);
|
||||
margin-inline: calc(-1 * var(--size-1));
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
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 { FaSolidAngleDown } from "solid-icons/fa";
|
||||
import css from './dropdown.module.css';
|
||||
|
||||
export interface DropdownApi {
|
||||
show(): void;
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
api?: (api: DropdownApi) => any,
|
||||
id: string;
|
||||
class?: string;
|
||||
open?: boolean;
|
||||
showCaret?: boolean;
|
||||
text: JSX.Element;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export function Dropdown(props: DropdownProps) {
|
||||
const [dialog, setDialog] = createSignal<HTMLDialogElement>();
|
||||
const [open, setOpen] = createSignal<boolean>(props.open ?? false);
|
||||
|
||||
createEffect(() => {
|
||||
dialog()?.[open() ? 'showPopover' : 'hidePopover']();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
props.api?.({
|
||||
show() {
|
||||
dialog()?.showPopover();
|
||||
},
|
||||
hide() {
|
||||
dialog()?.hidePopover();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return <section class={`${css.box} ${props.class}`}>
|
||||
<button id={`${props.id}_button`} popoverTarget={`${props.id}_dialog`} class={css.button}>
|
||||
{props.text}
|
||||
|
||||
<Show when={props.showCaret}>
|
||||
<FaSolidAngleDown class={css.caret} />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<dialog ref={setDialog} id={`${props.id}_dialog`} anchor={`${props.id}_button`} popover class={css.dialog} onToggle={e => setOpen(e.newState === 'open')}>
|
||||
{props.children}
|
||||
</dialog>
|
||||
</section>;
|
||||
import { createSignal, JSX, createEffect, Show } from "solid-js";
|
||||
import { FaSolidAngleDown } from "solid-icons/fa";
|
||||
import css from './dropdown.module.css';
|
||||
|
||||
export interface DropdownApi {
|
||||
show(): void;
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
api?: (api: DropdownApi) => any,
|
||||
id: string;
|
||||
class?: string;
|
||||
open?: boolean;
|
||||
showCaret?: boolean;
|
||||
text: JSX.Element;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export function Dropdown(props: DropdownProps) {
|
||||
const [dialog, setDialog] = createSignal<HTMLDialogElement>();
|
||||
const [open, setOpen] = createSignal<boolean>(props.open ?? false);
|
||||
|
||||
createEffect(() => {
|
||||
dialog()?.[open() ? 'showPopover' : 'hidePopover']();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
props.api?.({
|
||||
show() {
|
||||
dialog()?.showPopover();
|
||||
},
|
||||
hide() {
|
||||
dialog()?.hidePopover();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return <section class={`${css.box} ${props.class}`}>
|
||||
<button id={`${props.id}_button`} popoverTarget={`${props.id}_dialog`} class={css.button}>
|
||||
{props.text}
|
||||
|
||||
<Show when={props.showCaret}>
|
||||
<FaSolidAngleDown class={css.caret} />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<dialog ref={setDialog} id={`${props.id}_dialog`} anchor={`${props.id}_button`} popover class={css.dialog} onToggle={e => setOpen(e.newState === 'open')}>
|
||||
{props.children}
|
||||
</dialog>
|
||||
</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';
|
||||
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 {
|
||||
syntax: "<image>";
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
.container {
|
||||
isolation: isolate;
|
||||
display: block grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 100%;
|
||||
|
||||
container-type: inline-size;
|
||||
|
||||
overflow: hidden visible;
|
||||
scroll-snap-type: inline mandatory;
|
||||
overscroll-behavior-inline: contain;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
scroll-marker-group: after;
|
||||
|
||||
&::scroll-marker-group {
|
||||
display: block grid;
|
||||
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 5em;
|
||||
gap: 1rem;
|
||||
justify-content: start;
|
||||
|
||||
padding-inline: var(--size-6);
|
||||
|
||||
inline-size: 100%;
|
||||
block-size: 8.333333em;
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
--__i: var(--sibling-index);
|
||||
--__c: var(--sibling-count);
|
||||
scroll-snap-align: center;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid: repeat(3, auto) / 15em 1fr;
|
||||
grid-template-areas:
|
||||
"thumbnail . ."
|
||||
"thumbnail title cta"
|
||||
"thumbnail detail detail"
|
||||
"thumbnail summary summary";
|
||||
align-content: end;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: var(--size-6);
|
||||
block-size: 80vh;
|
||||
overflow: clip;
|
||||
container-type: scroll-state;
|
||||
|
||||
animation:
|
||||
animate-in linear forwards,
|
||||
animate-out linear forwards;
|
||||
animation-timeline: view(inline);
|
||||
animation-range: entry, exit;
|
||||
|
||||
color: var(--gray-0);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
background: linear-gradient(182.5deg, transparent 20%, var(--surface-2) 90%),
|
||||
linear-gradient(transparent 50%, #0007 75%);
|
||||
}
|
||||
|
||||
&::scroll-marker {
|
||||
display: block;
|
||||
content: " ";
|
||||
|
||||
inline-size: 5rem;
|
||||
aspect-ratio: 3 / 5;
|
||||
|
||||
background: var(--thumb-image) center / cover no-repeat;
|
||||
background-color: cornflowerblue;
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
transform: scale(1);
|
||||
transform-origin: top left;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
&::scroll-marker:target-current {
|
||||
/* outline: 1px solid white; */
|
||||
transform: translate(calc(-0cqi - (6rem * (var(--__i) - 1))), -29rem)
|
||||
scale(3);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 2.5em;
|
||||
z-index: 1;
|
||||
filter: contrast(9);
|
||||
}
|
||||
|
||||
.cta {
|
||||
grid-area: cta;
|
||||
z-index: 1;
|
||||
border-radius: var(--radius-2);
|
||||
background-color: var(--gray-2);
|
||||
color: var(--gray-8);
|
||||
text-decoration-color: var(--gray-8);
|
||||
padding: var(--size-3);
|
||||
font-weight: var(--font-weight-9);
|
||||
outline-offset: var(--size-1);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--gray-2);
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
grid-area: thumbnail;
|
||||
inline-size: 15em;
|
||||
aspect-ratio: 3 / 5;
|
||||
border-radius: var(--radius-3);
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
z-index: 1;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
grid-area: detail;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary {
|
||||
grid-area: summary;
|
||||
text-wrap: balance;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes animate-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
20% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@property --thumb-image {
|
||||
syntax: "<image>";
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
.container {
|
||||
isolation: isolate;
|
||||
display: block grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 100%;
|
||||
|
||||
container-type: inline-size;
|
||||
|
||||
overflow: hidden visible;
|
||||
scroll-snap-type: inline mandatory;
|
||||
overscroll-behavior-inline: contain;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
scroll-marker-group: after;
|
||||
|
||||
&::scroll-marker-group {
|
||||
display: block grid;
|
||||
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 5em;
|
||||
gap: 1rem;
|
||||
justify-content: start;
|
||||
|
||||
padding-inline: var(--size-6);
|
||||
|
||||
inline-size: 100%;
|
||||
block-size: 8.333333em;
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
--__i: var(--sibling-index);
|
||||
--__c: var(--sibling-count);
|
||||
scroll-snap-align: center;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid: repeat(3, auto) / 15em 1fr;
|
||||
grid-template-areas:
|
||||
"thumbnail . ."
|
||||
"thumbnail title cta"
|
||||
"thumbnail detail detail"
|
||||
"thumbnail summary summary";
|
||||
align-content: end;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: var(--size-6);
|
||||
block-size: 80vh;
|
||||
overflow: clip;
|
||||
container-type: scroll-state;
|
||||
|
||||
animation:
|
||||
animate-in linear forwards,
|
||||
animate-out linear forwards;
|
||||
animation-timeline: view(inline);
|
||||
animation-range: entry, exit;
|
||||
|
||||
color: var(--gray-0);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
background: linear-gradient(182.5deg, transparent 20%, var(--surface-2) 90%),
|
||||
linear-gradient(transparent 50%, #0007 75%);
|
||||
}
|
||||
|
||||
&::scroll-marker {
|
||||
display: block;
|
||||
content: " ";
|
||||
|
||||
inline-size: 5rem;
|
||||
aspect-ratio: 3 / 5;
|
||||
|
||||
background: var(--thumb-image) center / cover no-repeat;
|
||||
background-color: cornflowerblue;
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
transform: scale(1);
|
||||
transform-origin: top left;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
&::scroll-marker:target-current {
|
||||
/* outline: 1px solid white; */
|
||||
transform: translate(calc(-0cqi - (6rem * (var(--__i) - 1))), -29rem)
|
||||
scale(3);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: 2.5em;
|
||||
z-index: 1;
|
||||
filter: contrast(9);
|
||||
}
|
||||
|
||||
.cta {
|
||||
grid-area: cta;
|
||||
z-index: 1;
|
||||
border-radius: var(--radius-2);
|
||||
background-color: var(--gray-2);
|
||||
color: var(--gray-8);
|
||||
text-decoration-color: var(--gray-8);
|
||||
padding: var(--size-3);
|
||||
font-weight: var(--font-weight-9);
|
||||
outline-offset: var(--size-1);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--gray-2);
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
grid-area: thumbnail;
|
||||
inline-size: 15em;
|
||||
aspect-ratio: 3 / 5;
|
||||
border-radius: var(--radius-3);
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
z-index: 1;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
grid-area: detail;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary {
|
||||
grid-area: summary;
|
||||
text-wrap: balance;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes animate-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
20% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
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 { createSlug, Entry } from "~/features/content";
|
||||
import css from "./hero.module.css";
|
||||
|
||||
type HeroProps = {
|
||||
entries: Entry[];
|
||||
class?: string;
|
||||
};
|
||||
|
||||
export function Hero(props: HeroProps) {
|
||||
return (
|
||||
<div class={`${css.container} ${props.class ?? ""}`}>
|
||||
<For each={props.entries}>{(entry) => <Page entry={entry} />}</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Page: Component<{ entry: Entry }> = (props) => {
|
||||
const slug = createMemo(() => createSlug(props.entry));
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`${css.page}`}
|
||||
style={{ "--thumb-image": `url(${props.entry.thumbnail})` }}
|
||||
>
|
||||
<h2 class={css.title}>{props.entry.title}</h2>
|
||||
|
||||
<a class={css.cta} href={`/play/${slug()}`}>
|
||||
Continue
|
||||
</a>
|
||||
|
||||
<img src={props.entry.thumbnail} class={css.thumbnail} />
|
||||
{/* <img src={props.entry.image} class={css.background} /> */}
|
||||
|
||||
<video
|
||||
class={css.background}
|
||||
src={props.entry.trailer}
|
||||
poster={props.entry.image}
|
||||
muted
|
||||
autoplay
|
||||
/>
|
||||
|
||||
<span class={css.detail}>
|
||||
{props.entry.releaseDate}
|
||||
|
||||
<Index each={props.entry.sources ?? []}>
|
||||
{(source) => (
|
||||
<>
|
||||
•
|
||||
<a href={source().url.toString()} target="_blank">
|
||||
{source().rating.score} {source().label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Index>
|
||||
</span>
|
||||
|
||||
<p class={css.summary}>{props.entry.overview}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Component, createEffect, createMemo, For, Index } from "solid-js";
|
||||
import { createSlug, Entry } from "~/features/content";
|
||||
import css from "./hero.module.css";
|
||||
|
||||
type HeroProps = {
|
||||
entries: Entry[];
|
||||
class?: string;
|
||||
};
|
||||
|
||||
export function Hero(props: HeroProps) {
|
||||
return (
|
||||
<div class={`${css.container} ${props.class ?? ""}`}>
|
||||
<For each={props.entries}>{(entry) => <Page entry={entry} />}</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Page: Component<{ entry: Entry }> = (props) => {
|
||||
const slug = createMemo(() => createSlug(props.entry));
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`${css.page}`}
|
||||
style={{ "--thumb-image": `url(${props.entry.thumbnail})` }}
|
||||
>
|
||||
<h2 class={css.title}>{props.entry.title}</h2>
|
||||
|
||||
<a class={css.cta} href={`/play/${slug()}`}>
|
||||
Continue
|
||||
</a>
|
||||
|
||||
<img src={props.entry.thumbnail} class={css.thumbnail} />
|
||||
{/* <img src={props.entry.image} class={css.background} /> */}
|
||||
|
||||
<video
|
||||
class={css.background}
|
||||
src={props.entry.trailer}
|
||||
poster={props.entry.image}
|
||||
muted
|
||||
autoplay
|
||||
/>
|
||||
|
||||
<span class={css.detail}>
|
||||
{props.entry.releaseDate}
|
||||
|
||||
<Index each={props.entry.sources ?? []}>
|
||||
{(source) => (
|
||||
<>
|
||||
•
|
||||
<a href={source().url.toString()} target="_blank">
|
||||
{source().rating.score} {source().label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Index>
|
||||
</span>
|
||||
|
||||
<p class={css.summary}>{props.entry.overview}</p>
|
||||
</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 {
|
||||
--_space: var(--size-6);
|
||||
display: grid;
|
||||
grid: auto auto / auto auto;
|
||||
grid-template-areas:
|
||||
"heading metadata"
|
||||
"list list";
|
||||
justify-content: space-between;
|
||||
inline-size: 100%;
|
||||
|
||||
padding-inline: var(--_space);
|
||||
}
|
||||
|
||||
.heading {
|
||||
grid-area: heading;
|
||||
font-size: var(--size-7);
|
||||
color: var(--text-1);
|
||||
|
||||
padding-inline: var(--_space);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
grid-area: metadata;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.list {
|
||||
grid-area: list;
|
||||
list-style-type: none;
|
||||
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
|
||||
gap: var(--_space);
|
||||
padding: calc(8 * var(--_space)) calc(2 * var(--_space)) calc(2.5 * var(--_space));
|
||||
scroll-padding: calc(2 * var(--_space));
|
||||
margin: calc(-7 * var(--_space)) calc(-1 * var(--_space)) 0em;
|
||||
|
||||
overflow: visible auto;
|
||||
scroll-snap-type: inline mandatory;
|
||||
overscroll-behavior-inline: contain;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* the before and afters have unsnappable elements that create bouncy edges to the scroll */
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
}
|
||||
|
||||
&::before {
|
||||
inline-size: 15cqi;
|
||||
}
|
||||
|
||||
&::after {
|
||||
inline-size: 100cqi;
|
||||
}
|
||||
|
||||
& > li {
|
||||
scroll-snap-align: start;
|
||||
container-type: scroll-state;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
z-index: calc(var(--sibling-count) - var(--sibling-index));
|
||||
|
||||
&:has(> :hover, > :focus-within) {
|
||||
z-index: calc(var(--sibling-count) + 1);
|
||||
}
|
||||
|
||||
& > * {
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: slide-in linear both;
|
||||
animation-timeline: view(inline);
|
||||
animation-range: cover -100cqi contain 15cqi;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(-100cqi) scale(0.5);
|
||||
}
|
||||
}
|
||||
.container {
|
||||
--_space: var(--size-6);
|
||||
display: grid;
|
||||
grid: auto auto / auto auto;
|
||||
grid-template-areas:
|
||||
"heading metadata"
|
||||
"list list";
|
||||
justify-content: space-between;
|
||||
inline-size: 100%;
|
||||
|
||||
padding-inline: var(--_space);
|
||||
}
|
||||
|
||||
.heading {
|
||||
grid-area: heading;
|
||||
font-size: var(--size-7);
|
||||
color: var(--text-1);
|
||||
|
||||
padding-inline: var(--_space);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
grid-area: metadata;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.list {
|
||||
grid-area: list;
|
||||
list-style-type: none;
|
||||
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
|
||||
gap: var(--_space);
|
||||
padding: calc(8 * var(--_space)) calc(2 * var(--_space)) calc(2.5 * var(--_space));
|
||||
scroll-padding: calc(2 * var(--_space));
|
||||
margin: calc(-7 * var(--_space)) calc(-1 * var(--_space)) 0em;
|
||||
|
||||
overflow: visible auto;
|
||||
scroll-snap-type: inline mandatory;
|
||||
overscroll-behavior-inline: contain;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* the before and afters have unsnappable elements that create bouncy edges to the scroll */
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
}
|
||||
|
||||
&::before {
|
||||
inline-size: 15cqi;
|
||||
}
|
||||
|
||||
&::after {
|
||||
inline-size: 100cqi;
|
||||
}
|
||||
|
||||
& > li {
|
||||
scroll-snap-align: start;
|
||||
container-type: scroll-state;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
z-index: calc(var(--sibling-count) - var(--sibling-index));
|
||||
|
||||
&:has(> :hover, > :focus-within) {
|
||||
z-index: calc(var(--sibling-count) + 1);
|
||||
}
|
||||
|
||||
& > * {
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: slide-in linear both;
|
||||
animation-timeline: view(inline);
|
||||
animation-range: cover -100cqi contain 15cqi;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
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 css from "./list.module.css";
|
||||
|
||||
interface ListProps<T> {
|
||||
label: string;
|
||||
items: T[];
|
||||
class?: string;
|
||||
children: (item: Accessor<T>) => JSX.Element;
|
||||
}
|
||||
|
||||
export function List<T>(props: ListProps<T>) {
|
||||
return (
|
||||
<section class={`${css.container} ${props.class ?? ""}`}>
|
||||
<b role="heading" class={css.heading}>
|
||||
{props.label}
|
||||
</b>
|
||||
|
||||
<sub class={css.metadata}>{props.items.length} result(s)</sub>
|
||||
|
||||
<ul class={css.list}>
|
||||
<Index each={props.items}>
|
||||
{(item) => <li>{props.children(item)}</li>}
|
||||
</Index>
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { Accessor, Index, JSX } from "solid-js";
|
||||
import css from "./list.module.css";
|
||||
|
||||
interface ListProps<T> {
|
||||
label: string;
|
||||
items: T[];
|
||||
class?: string;
|
||||
children: (item: Accessor<T>) => JSX.Element;
|
||||
}
|
||||
|
||||
export function List<T>(props: ListProps<T>) {
|
||||
return (
|
||||
<section class={`${css.container} ${props.class ?? ""}`}>
|
||||
<b role="heading" class={css.heading}>
|
||||
{props.label}
|
||||
</b>
|
||||
|
||||
<sub class={css.metadata}>{props.items.length} result(s)</sub>
|
||||
|
||||
<ul class={css.list}>
|
||||
<Index each={props.items}>
|
||||
{(item) => <li>{props.children(item)}</li>}
|
||||
</Index>
|
||||
</ul>
|
||||
</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';
|
||||
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 {
|
||||
appearance: none;
|
||||
|
||||
display: block grid;
|
||||
place-items: center start;
|
||||
|
||||
padding: var(--size-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-700);
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
|
||||
&,
|
||||
&::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
&::picker(select) {
|
||||
/* display: block grid;
|
||||
row-gap: var(--size-2); */
|
||||
|
||||
background-color: var(--surface-3);
|
||||
padding: var(--size-2) 0;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
opacity: 0;
|
||||
block-size: 0;
|
||||
overflow: clip;
|
||||
transition:
|
||||
height 0.5s ease-out,
|
||||
opacity 0.5s ease-out,
|
||||
overlay 0.5s,
|
||||
display 0.5s,
|
||||
overflow 0.5s;
|
||||
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
&:open {
|
||||
background-color: var(--surface-3);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&::picker(select) {
|
||||
opacity: 1;
|
||||
block-size: calc-size(auto, size);
|
||||
overflow: auto;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
block-size: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > option {
|
||||
display: block grid;
|
||||
grid-auto-flow: column;
|
||||
place-items: center start;
|
||||
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--size-2);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:checked {
|
||||
background-color: var(--surface-4);
|
||||
}
|
||||
|
||||
&::checkmark {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* .box {
|
||||
display: contents;
|
||||
|
||||
&:has(> :popover-open) > .button {
|
||||
background-color: var(--surface-500);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: inherit;
|
||||
place-items: center start;
|
||||
|
||||
block-size: 1em;
|
||||
box-sizing: content-box;
|
||||
|
||||
padding: var(--size-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 1rem;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-700);
|
||||
}
|
||||
|
||||
&:has(> .caret) {
|
||||
padding-inline-end: calc(1em + (2 * var(--size-2)));
|
||||
}
|
||||
|
||||
& > .caret {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--size-2);
|
||||
inset-block-start: 50%;
|
||||
translate: 0 -50%;
|
||||
inline-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: none;
|
||||
position: relative;
|
||||
grid-template-columns: inherit;
|
||||
|
||||
inset-inline-start: anchor(start);
|
||||
inset-block-start: anchor(end);
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
|
||||
background-color: var(--surface-3);
|
||||
padding: var(--size-2);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
&:popover-open {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
& > header {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
gap: var(--padding-s);
|
||||
}
|
||||
|
||||
& > main {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
row-gap: var(--padding-s);
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
place-items: center start;
|
||||
|
||||
border-radius: var(--radii-m);
|
||||
padding: var(--padding-s);
|
||||
margin-inline: calc(-1 * var(--padding-s));
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background-color: oklch(from var(--info) l c h / .1);
|
||||
}
|
||||
.box {
|
||||
appearance: none;
|
||||
|
||||
display: block grid;
|
||||
place-items: center start;
|
||||
|
||||
padding: var(--size-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-700);
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
|
||||
&,
|
||||
&::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
&::picker(select) {
|
||||
/* display: block grid;
|
||||
row-gap: var(--size-2); */
|
||||
|
||||
background-color: var(--surface-3);
|
||||
padding: var(--size-2) 0;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
opacity: 0;
|
||||
block-size: 0;
|
||||
overflow: clip;
|
||||
transition:
|
||||
height 0.5s ease-out,
|
||||
opacity 0.5s ease-out,
|
||||
overlay 0.5s,
|
||||
display 0.5s,
|
||||
overflow 0.5s;
|
||||
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
&:open {
|
||||
background-color: var(--surface-3);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&::picker(select) {
|
||||
opacity: 1;
|
||||
block-size: calc-size(auto, size);
|
||||
overflow: auto;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
block-size: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > option {
|
||||
display: block grid;
|
||||
grid-auto-flow: column;
|
||||
place-items: center start;
|
||||
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--size-2);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:checked {
|
||||
background-color: var(--surface-4);
|
||||
}
|
||||
|
||||
&::checkmark {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* .box {
|
||||
display: contents;
|
||||
|
||||
&:has(> :popover-open) > .button {
|
||||
background-color: var(--surface-500);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: inherit;
|
||||
place-items: center start;
|
||||
|
||||
block-size: 1em;
|
||||
box-sizing: content-box;
|
||||
|
||||
padding: var(--size-2);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 1rem;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-700);
|
||||
}
|
||||
|
||||
&:has(> .caret) {
|
||||
padding-inline-end: calc(1em + (2 * var(--size-2)));
|
||||
}
|
||||
|
||||
& > .caret {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--size-2);
|
||||
inset-block-start: 50%;
|
||||
translate: 0 -50%;
|
||||
inline-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: none;
|
||||
position: relative;
|
||||
grid-template-columns: inherit;
|
||||
|
||||
inset-inline-start: anchor(start);
|
||||
inset-block-start: anchor(end);
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
|
||||
background-color: var(--surface-3);
|
||||
padding: var(--size-2);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
&:popover-open {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
& > header {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
gap: var(--padding-s);
|
||||
}
|
||||
|
||||
& > main {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
row-gap: var(--padding-s);
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
place-items: center start;
|
||||
|
||||
border-radius: var(--radii-m);
|
||||
padding: var(--padding-s);
|
||||
margin-inline: calc(-1 * var(--padding-s));
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
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 { Dropdown, DropdownApi } from "../dropdown";
|
||||
import css from './select.module.css';
|
||||
|
||||
interface SelectProps<T, K extends string> {
|
||||
id: string;
|
||||
class?: string;
|
||||
value: K;
|
||||
setValue?: Setter<K>;
|
||||
values: Record<K, T>;
|
||||
open?: boolean;
|
||||
showCaret?: boolean;
|
||||
children: (key: K, value: T) => JSX.Element;
|
||||
filter?: (query: string, key: K, value: T) => boolean;
|
||||
}
|
||||
|
||||
export function Select<T, K extends string>(props: SelectProps<T, K>) {
|
||||
const [dropdown, setDropdown] = createSignal<DropdownApi>();
|
||||
const [key, setKey] = createSignal<K>(props.value);
|
||||
const [query, setQuery] = createSignal<string>('');
|
||||
|
||||
const showCaret = createMemo(() => props.showCaret ?? true);
|
||||
const values = createMemo(() => {
|
||||
let entries = Object.entries<T>(props.values) as [K, T][];
|
||||
const filter = props.filter;
|
||||
const q = query();
|
||||
|
||||
if (filter) {
|
||||
entries = entries.filter(([k, v]) => filter(q, k, v));
|
||||
}
|
||||
|
||||
return entries;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
props.setValue?.(() => key());
|
||||
});
|
||||
|
||||
const text = <Show when={key()}>{
|
||||
key => {
|
||||
const value = createMemo(() => props.values[key()]);
|
||||
|
||||
return <>{props.children(key(), value())}</>;
|
||||
}
|
||||
}</Show>
|
||||
|
||||
return <Dropdown api={setDropdown} id={props.id} class={`${css.box} ${props.class}`} showCaret={showCaret()} open={props.open} text={text}>
|
||||
<Show when={props.filter !== undefined}>
|
||||
<header>
|
||||
<input value={query()} onInput={e => setQuery(e.target.value)} />
|
||||
</header>
|
||||
</Show>
|
||||
|
||||
<main>
|
||||
<For each={values()}>{
|
||||
([k, v]) => {
|
||||
const selected = createMemo(() => key() === k);
|
||||
|
||||
return <span class={`${css.option} ${selected() ? css.selected : ''}`} onpointerdown={() => {
|
||||
setKey(() => k);
|
||||
dropdown()?.hide();
|
||||
}}>{props.children(k, v)}</span>;
|
||||
}
|
||||
}</For>
|
||||
</main>
|
||||
</Dropdown>
|
||||
import { createMemo, createSignal, For, JSX, Setter, createEffect, Show } from "solid-js";
|
||||
import { Dropdown, DropdownApi } from "../dropdown";
|
||||
import css from './select.module.css';
|
||||
|
||||
interface SelectProps<T, K extends string> {
|
||||
id: string;
|
||||
class?: string;
|
||||
value: K;
|
||||
setValue?: Setter<K>;
|
||||
values: Record<K, T>;
|
||||
open?: boolean;
|
||||
showCaret?: boolean;
|
||||
children: (key: K, value: T) => JSX.Element;
|
||||
filter?: (query: string, key: K, value: T) => boolean;
|
||||
}
|
||||
|
||||
export function Select<T, K extends string>(props: SelectProps<T, K>) {
|
||||
const [dropdown, setDropdown] = createSignal<DropdownApi>();
|
||||
const [key, setKey] = createSignal<K>(props.value);
|
||||
const [query, setQuery] = createSignal<string>('');
|
||||
|
||||
const showCaret = createMemo(() => props.showCaret ?? true);
|
||||
const values = createMemo(() => {
|
||||
let entries = Object.entries<T>(props.values) as [K, T][];
|
||||
const filter = props.filter;
|
||||
const q = query();
|
||||
|
||||
if (filter) {
|
||||
entries = entries.filter(([k, v]) => filter(q, k, v));
|
||||
}
|
||||
|
||||
return entries;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
props.setValue?.(() => key());
|
||||
});
|
||||
|
||||
const text = <Show when={key()}>{
|
||||
key => {
|
||||
const value = createMemo(() => props.values[key()]);
|
||||
|
||||
return <>{props.children(key(), value())}</>;
|
||||
}
|
||||
}</Show>
|
||||
|
||||
return <Dropdown api={setDropdown} id={props.id} class={`${css.box} ${props.class}`} showCaret={showCaret()} open={props.open} text={text}>
|
||||
<Show when={props.filter !== undefined}>
|
||||
<header>
|
||||
<input value={query()} onInput={e => setQuery(e.target.value)} />
|
||||
</header>
|
||||
</Show>
|
||||
|
||||
<main>
|
||||
<For each={values()}>{
|
||||
([k, v]) => {
|
||||
const selected = createMemo(() => key() === k);
|
||||
|
||||
return <span class={`${css.option} ${selected() ? css.selected : ''}`} onpointerdown={() => {
|
||||
setKey(() => k);
|
||||
dropdown()?.hide();
|
||||
}}>{props.children(k, v)}</span>;
|
||||
}
|
||||
}</For>
|
||||
</main>
|
||||
</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 {
|
||||
grid-area: 2 / 1 / 3 / 2;
|
||||
display: block grid;
|
||||
grid-auto-flow: row;
|
||||
justify-content: space-between;
|
||||
inline-size: 5em;
|
||||
block-size: 100%;
|
||||
padding: 1em;
|
||||
background: inherit;
|
||||
z-index: 0;
|
||||
transition: z-index 0.3s step-end;
|
||||
|
||||
& > ul {
|
||||
position: relative;
|
||||
display: block grid;
|
||||
grid-template-columns: 2.5rem auto;
|
||||
align-content: center;
|
||||
inline-size: 4rem;
|
||||
gap: 1rem;
|
||||
transform-origin: left center;
|
||||
padding: 0;
|
||||
padding-inline-start: 0.5rem;
|
||||
margin: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 100%;
|
||||
inset-block: -1em;
|
||||
inline-size: 40vw;
|
||||
background-image: linear-gradient(to right, rgb(from var(--surface-1) r g b / .9) 50%, transparent);
|
||||
mask: radial-gradient(
|
||||
ellipse 40vw 100% at left center,
|
||||
black 25%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(5px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s var(--ease-3);
|
||||
}
|
||||
|
||||
& > a {
|
||||
position: relative;
|
||||
grid-column: span 2;
|
||||
display: block grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transform-origin: center left;
|
||||
transition:
|
||||
transform 2s var(--ease-spring-5),
|
||||
opacity 0.3s var(--ease-3);
|
||||
color: var(--text-2);
|
||||
font-size: 2rem;
|
||||
line-height: 1.5;
|
||||
|
||||
& > span {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s var(--ease-3);
|
||||
text-shadow: 0 0 .5em var(--surface-1);
|
||||
}
|
||||
|
||||
& > svg {
|
||||
fill: var(--text-2);
|
||||
inline-size: 2.5rem;
|
||||
block-size: 2.5rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--yellow-5);
|
||||
list-style: disc;
|
||||
|
||||
&::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
inset-inline-start: -1rem;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
fill: var(--yellow-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus)) {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > a {
|
||||
transform: scale(max(1, calc(1.5 - (0.2 * abs(var(--target) - var(--sibling-index))))));
|
||||
|
||||
& > span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus)) > a:not(:is(:hover, :focus)) {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(1)) {
|
||||
--target: 1;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(2)) {
|
||||
--target: 2;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(3)) {
|
||||
--target: 3;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(4)) {
|
||||
--target: 4;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(5)) {
|
||||
--target: 5;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(6)) {
|
||||
--target: 6;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(7)) {
|
||||
--target: 7;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(8)) {
|
||||
--target: 8;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(9)) {
|
||||
--target: 9;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(10)) {
|
||||
--target: 10;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(11)) {
|
||||
--target: 11;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(12)) {
|
||||
--target: 12;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(13)) {
|
||||
--target: 13;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(14)) {
|
||||
--target: 14;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(15)) {
|
||||
--target: 15;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(a:hover, :focus-within) {
|
||||
z-index: 1;
|
||||
transition: z-index 0.3s step-start;
|
||||
}
|
||||
}
|
||||
.nav {
|
||||
grid-area: 2 / 1 / 3 / 2;
|
||||
display: block grid;
|
||||
grid-auto-flow: row;
|
||||
justify-content: space-between;
|
||||
inline-size: 5em;
|
||||
block-size: 100%;
|
||||
padding: 1em;
|
||||
background: inherit;
|
||||
z-index: 0;
|
||||
transition: z-index 0.3s step-end;
|
||||
|
||||
& > ul {
|
||||
position: relative;
|
||||
display: block grid;
|
||||
grid-template-columns: 2.5rem auto;
|
||||
align-content: center;
|
||||
inline-size: 4rem;
|
||||
gap: 1rem;
|
||||
transform-origin: left center;
|
||||
padding: 0;
|
||||
padding-inline-start: 0.5rem;
|
||||
margin: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 100%;
|
||||
inset-block: -1em;
|
||||
inline-size: 40vw;
|
||||
background-image: linear-gradient(to right, rgb(from var(--surface-1) r g b / .9) 50%, transparent);
|
||||
mask: radial-gradient(
|
||||
ellipse 40vw 100% at left center,
|
||||
black 25%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(5px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s var(--ease-3);
|
||||
}
|
||||
|
||||
& > a {
|
||||
position: relative;
|
||||
grid-column: span 2;
|
||||
display: block grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transform-origin: center left;
|
||||
transition:
|
||||
transform 2s var(--ease-spring-5),
|
||||
opacity 0.3s var(--ease-3);
|
||||
color: var(--text-2);
|
||||
font-size: 2rem;
|
||||
line-height: 1.5;
|
||||
|
||||
& > span {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s var(--ease-3);
|
||||
text-shadow: 0 0 .5em var(--surface-1);
|
||||
}
|
||||
|
||||
& > svg {
|
||||
fill: var(--text-2);
|
||||
inline-size: 2.5rem;
|
||||
block-size: 2.5rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--yellow-5);
|
||||
list-style: disc;
|
||||
|
||||
&::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
inset-inline-start: -1rem;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
fill: var(--yellow-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus)) {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > a {
|
||||
transform: scale(max(1, calc(1.5 - (0.2 * abs(var(--target) - var(--sibling-index))))));
|
||||
|
||||
& > span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus)) > a:not(:is(:hover, :focus)) {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(1)) {
|
||||
--target: 1;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(2)) {
|
||||
--target: 2;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(3)) {
|
||||
--target: 3;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(4)) {
|
||||
--target: 4;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(5)) {
|
||||
--target: 5;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(6)) {
|
||||
--target: 6;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(7)) {
|
||||
--target: 7;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(8)) {
|
||||
--target: 8;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(9)) {
|
||||
--target: 9;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(10)) {
|
||||
--target: 10;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(11)) {
|
||||
--target: 11;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(12)) {
|
||||
--target: 12;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(13)) {
|
||||
--target: 13;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(14)) {
|
||||
--target: 14;
|
||||
}
|
||||
|
||||
&:has(a:is(:hover, :focus):nth-child(15)) {
|
||||
--target: 15;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(a:hover, :focus-within) {
|
||||
z-index: 1;
|
||||
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 { AiOutlineHome, AiOutlineStar, AiOutlineSearch } from "solid-icons/ai";
|
||||
import { Component } from "solid-js";
|
||||
import css from "./nav.module.css";
|
||||
|
||||
export const Nav: Component = (props) => {
|
||||
return (
|
||||
<nav class={css.nav}>
|
||||
<ul>
|
||||
<A href="/" activeClass={css.active} end={true}>
|
||||
<AiOutlineHome />
|
||||
<span>Home</span>
|
||||
</A>
|
||||
<A href="/library" activeClass={css.active}>
|
||||
<AiOutlineStar />
|
||||
<span>Library</span>
|
||||
</A>
|
||||
<A href="/search" activeClass={css.active}>
|
||||
<AiOutlineSearch />
|
||||
<span>Search</span>
|
||||
</A>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
import { A } from "@solidjs/router";
|
||||
import { AiOutlineHome, AiOutlineStar, AiOutlineSearch } from "solid-icons/ai";
|
||||
import { Component } from "solid-js";
|
||||
import css from "./nav.module.css";
|
||||
|
||||
export const Nav: Component = (props) => {
|
||||
return (
|
||||
<nav class={css.nav}>
|
||||
<ul>
|
||||
<A href="/" activeClass={css.active} end={true}>
|
||||
<AiOutlineHome />
|
||||
<span>Home</span>
|
||||
</A>
|
||||
<A href="/library" activeClass={css.active}>
|
||||
<AiOutlineStar />
|
||||
<span>Library</span>
|
||||
</A>
|
||||
<A href="/search" activeClass={css.active}>
|
||||
<AiOutlineSearch />
|
||||
<span>Search</span>
|
||||
</A>
|
||||
</ul>
|
||||
</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 {
|
||||
position: relative;
|
||||
display: block grid;
|
||||
grid: auto 1fr / 5em 1fr;
|
||||
grid-template-areas:
|
||||
"top top"
|
||||
"nav content";
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
z-index: 0;
|
||||
overflow: clip;
|
||||
container-type: inline-size;
|
||||
background-color: var(--surface-1);
|
||||
contain: layout style paint;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
grid-area: content;
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-start: 0;
|
||||
inline-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));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
grid-area: 2 / 1 / 3 / 3;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
background: linear-gradient(180deg,
|
||||
transparent,
|
||||
transparent 90vh,
|
||||
var(--surface-500) 90vh,
|
||||
var(--surface-500));
|
||||
overflow: clip auto;
|
||||
padding-inline-start: 5em;
|
||||
transition: filter var(--duration-moderate-1) var(--ease-3);
|
||||
container-type: size;
|
||||
|
||||
& > div {
|
||||
background-color: var(--surface-2);
|
||||
container-type: inline-size;
|
||||
contain: layout style paint;
|
||||
inline-size: 100%;
|
||||
block-size: fit-content;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
display: block grid;
|
||||
grid: auto 1fr / 5em 1fr;
|
||||
grid-template-areas:
|
||||
"top top"
|
||||
"nav content";
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
z-index: 0;
|
||||
overflow: clip;
|
||||
container-type: inline-size;
|
||||
background-color: var(--surface-1);
|
||||
contain: layout style paint;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
grid-area: content;
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-start: 0;
|
||||
inline-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));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
grid-area: 2 / 1 / 3 / 3;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
background: linear-gradient(180deg,
|
||||
transparent,
|
||||
transparent 90vh,
|
||||
var(--surface-500) 90vh,
|
||||
var(--surface-500));
|
||||
overflow: clip auto;
|
||||
padding-inline-start: 5em;
|
||||
transition: filter var(--duration-moderate-1) var(--ease-3);
|
||||
container-type: size;
|
||||
|
||||
& > div {
|
||||
background-color: var(--surface-2);
|
||||
container-type: inline-size;
|
||||
contain: layout style paint;
|
||||
inline-size: 100%;
|
||||
block-size: fit-content;
|
||||
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 { Top } from "./top";
|
||||
import { Nav } from "./nav";
|
||||
import css from "./shell.module.css";
|
||||
import { User } from "../user";
|
||||
|
||||
interface ShellProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Shell: ParentComponent<ShellProps> = (props) => {
|
||||
return (
|
||||
<main class={css.container}>
|
||||
<Top user={props.user} />
|
||||
<Nav />
|
||||
|
||||
<div class={css.body}>
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
import { ParentComponent } from "solid-js";
|
||||
import { Top } from "./top";
|
||||
import { Nav } from "./nav";
|
||||
import css from "./shell.module.css";
|
||||
import { User } from "../user";
|
||||
|
||||
interface ShellProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Shell: ParentComponent<ShellProps> = (props) => {
|
||||
return (
|
||||
<main class={css.container}>
|
||||
<Top user={props.user} />
|
||||
<Nav />
|
||||
|
||||
<div class={css.body}>
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</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 {
|
||||
grid-area: 1 / 1 / 2 / 3;
|
||||
display: block grid;
|
||||
grid-auto-flow: column;
|
||||
justify-content: end;
|
||||
z-index: 1;
|
||||
background-color: inherit;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.accountTrigger {
|
||||
anchor-name: --account-trigger;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-round);
|
||||
}
|
||||
|
||||
.accountMenu {
|
||||
position-anchor: --account-trigger;
|
||||
position: absolute;
|
||||
inset: auto;
|
||||
inset-inline-end: anchor(end);
|
||||
inset-block-start: anchor(start);
|
||||
|
||||
display: block grid;
|
||||
grid-auto-flow: row;
|
||||
gap: var(--size-3);
|
||||
padding: var(--size-3);
|
||||
background-color: light-dark(var(--gray-1), var(--gray-9));
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
&:not(:popover-open) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.top {
|
||||
grid-area: 1 / 1 / 2 / 3;
|
||||
display: block grid;
|
||||
grid-auto-flow: column;
|
||||
justify-content: end;
|
||||
z-index: 1;
|
||||
background-color: inherit;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.accountTrigger {
|
||||
anchor-name: --account-trigger;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-round);
|
||||
}
|
||||
|
||||
.accountMenu {
|
||||
position-anchor: --account-trigger;
|
||||
position: absolute;
|
||||
inset: auto;
|
||||
inset-inline-end: anchor(end);
|
||||
inset-block-start: anchor(start);
|
||||
|
||||
display: block grid;
|
||||
grid-auto-flow: row;
|
||||
gap: var(--size-3);
|
||||
padding: var(--size-3);
|
||||
background-color: light-dark(var(--gray-1), var(--gray-9));
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
&:not(:popover-open) {
|
||||
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 { signIn, signOut, client } from "~/auth.client";
|
||||
import { Avatar, Profile, User } from "../user";
|
||||
import { ColorSchemePicker } from "../theme";
|
||||
import css from "./top.module.css";
|
||||
|
||||
interface TopProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Top: Component<TopProps> = (props) => {
|
||||
const login = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await signIn.oauth2({
|
||||
providerId: "authelia",
|
||||
callbackURL: "/",
|
||||
});
|
||||
};
|
||||
|
||||
const logout = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside class={css.top}>
|
||||
<Show
|
||||
when={props.user}
|
||||
fallback={
|
||||
<form method="post" onSubmit={login}>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
{(user) => (
|
||||
<>
|
||||
<button
|
||||
class={css.accountTrigger}
|
||||
id="account-menu-trigger"
|
||||
popovertarget="account-menu-popover"
|
||||
>
|
||||
<Avatar user={user()} />
|
||||
</button>
|
||||
<div class={css.accountMenu} id="account-menu-popover" popover>
|
||||
<Profile user={user()} />
|
||||
<a href="/settings">Settings</a>
|
||||
<form method="post" onSubmit={logout}>
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<ColorSchemePicker />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
import { Component, Show } from "solid-js";
|
||||
import { signIn, signOut, client } from "~/auth.client";
|
||||
import { Avatar, Profile, User } from "../user";
|
||||
import { ColorSchemePicker } from "../theme";
|
||||
import css from "./top.module.css";
|
||||
|
||||
interface TopProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Top: Component<TopProps> = (props) => {
|
||||
const login = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await signIn.oauth2({
|
||||
providerId: "authelia",
|
||||
callbackURL: "/",
|
||||
});
|
||||
};
|
||||
|
||||
const logout = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside class={css.top}>
|
||||
<Show
|
||||
when={props.user}
|
||||
fallback={
|
||||
<form method="post" onSubmit={login}>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
{(user) => (
|
||||
<>
|
||||
<button
|
||||
class={css.accountTrigger}
|
||||
id="account-menu-trigger"
|
||||
popovertarget="account-menu-popover"
|
||||
>
|
||||
<Avatar user={user()} />
|
||||
</button>
|
||||
<div class={css.accountMenu} id="account-menu-popover" popover>
|
||||
<Profile user={user()} />
|
||||
<a href="/settings">Settings</a>
|
||||
<form method="post" onSubmit={logout}>
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<ColorSchemePicker />
|
||||
</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 {
|
||||
inline-size: var(--size-8);
|
||||
border-radius: var(--radius-round);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
.avatar {
|
||||
inline-size: var(--size-8);
|
||||
border-radius: var(--radius-round);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
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 { User } from "./user";
|
||||
import { hash } from "~/utilities";
|
||||
import css from "./avatar.module.css";
|
||||
|
||||
interface AvatarProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Avatar: Component<AvatarProps> = (props) => {
|
||||
const hashedEmail = hash("SHA-256", () => props.user?.email);
|
||||
const src = createMemo(() => {
|
||||
const user = props.user;
|
||||
|
||||
if (user === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (user.image === null) {
|
||||
return `https://www.gravatar.com/avatar/${hashedEmail()}`;
|
||||
}
|
||||
|
||||
return user.image;
|
||||
});
|
||||
|
||||
return <img src={src()} class={css.avatar} />;
|
||||
};
|
||||
import { Component, createMemo, Show } from "solid-js";
|
||||
import { User } from "./user";
|
||||
import { hash } from "~/utilities";
|
||||
import css from "./avatar.module.css";
|
||||
|
||||
interface AvatarProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Avatar: Component<AvatarProps> = (props) => {
|
||||
const hashedEmail = hash("SHA-256", () => props.user?.email);
|
||||
const src = createMemo(() => {
|
||||
const user = props.user;
|
||||
|
||||
if (user === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (user.image === null) {
|
||||
return `https://www.gravatar.com/avatar/${hashedEmail()}`;
|
||||
}
|
||||
|
||||
return user.image;
|
||||
});
|
||||
|
||||
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 { Avatar } from "./avatar";
|
||||
export { Profile } from "./profile";
|
||||
export type { User } from "./user";
|
||||
|
||||
export { Avatar } from "./avatar";
|
||||
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 {
|
||||
display: block grid;
|
||||
grid: auto 1fr / auto 1fr;
|
||||
gap: var(--size-2);
|
||||
place-content: start;
|
||||
background-color: light-dark(var(--gray-1), var(--gray-9));
|
||||
|
||||
& > img {
|
||||
grid-area: span 2 / 1;
|
||||
}
|
||||
|
||||
& > strong {
|
||||
font-size: var(--size-4);
|
||||
line-height: 1;
|
||||
color: light-dark(var(--gray-7), var(--gray-3));
|
||||
}
|
||||
|
||||
& > span {
|
||||
line-height: 1;
|
||||
color: light-dark(var(--gray-4), var(--gray-6));
|
||||
}
|
||||
}
|
||||
.profile {
|
||||
display: block grid;
|
||||
grid: auto 1fr / auto 1fr;
|
||||
gap: var(--size-2);
|
||||
place-content: start;
|
||||
background-color: light-dark(var(--gray-1), var(--gray-9));
|
||||
|
||||
& > img {
|
||||
grid-area: span 2 / 1;
|
||||
}
|
||||
|
||||
& > strong {
|
||||
font-size: var(--size-4);
|
||||
line-height: 1;
|
||||
color: light-dark(var(--gray-7), var(--gray-3));
|
||||
}
|
||||
|
||||
& > span {
|
||||
line-height: 1;
|
||||
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 { User } from "./user";
|
||||
import { Avatar } from "./avatar";
|
||||
import css from "./profile.module.css";
|
||||
|
||||
interface ProfileProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Profile: Component<ProfileProps> = (props) => {
|
||||
return (
|
||||
<div class={css.profile}>
|
||||
<Avatar user={props.user} />
|
||||
<strong>{props.user?.name ?? ""}</strong>
|
||||
<span>{props.user?.email ?? ""}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Component } from "solid-js";
|
||||
import { User } from "./user";
|
||||
import { Avatar } from "./avatar";
|
||||
import css from "./profile.module.css";
|
||||
|
||||
interface ProfileProps {
|
||||
user: User | undefined;
|
||||
}
|
||||
|
||||
export const Profile: Component<ProfileProps> = (props) => {
|
||||
return (
|
||||
<div class={css.profile}>
|
||||
<Avatar user={props.user} />
|
||||
<strong>{props.user?.name ?? ""}</strong>
|
||||
<span>{props.user?.email ?? ""}</span>
|
||||
</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 {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
export interface User {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
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";
|
||||
|
||||
export const splitAt = (
|
||||
subject: string,
|
||||
index: number,
|
||||
): readonly [string, string] => {
|
||||
if (index < 0) {
|
||||
return [subject, ""];
|
||||
}
|
||||
|
||||
if (index > subject.length) {
|
||||
return [subject, ""];
|
||||
}
|
||||
|
||||
return [subject.slice(0, index), subject.slice(index + 1)];
|
||||
};
|
||||
|
||||
export const toSlug = (subject: string) =>
|
||||
subject.toLowerCase().replaceAll(" ", "-").replaceAll(/[^\w-]/gi, "");
|
||||
export const toHex = (subject: number) => subject.toString(16).padStart(2, "0");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
export const hash = (
|
||||
algorithm: AlgorithmIdentifier,
|
||||
subject: Accessor<string | null | undefined>,
|
||||
) => {
|
||||
const [hash, setHash] = createSignal<string>();
|
||||
|
||||
createEffect(
|
||||
on(subject, async (subject) => {
|
||||
if (subject === null || subject === undefined || subject.length === 0) {
|
||||
setHash(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(
|
||||
await crypto.subtle.digest(algorithm, encoder.encode(subject)),
|
||||
);
|
||||
|
||||
setHash(Array.from(buffer).map(toHex).join(""));
|
||||
}),
|
||||
);
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
export const merge = (...objects: Record<string, any>[]): Record<string, any> => {
|
||||
if (objects.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const target = objects[0];
|
||||
|
||||
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]);
|
||||
|
||||
target[key] = values.every(v => v && typeof v === 'object' && !Array.isArray(v)) ? merge(...values) : values.at(-1);
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
type CamelCase<S extends string> = S extends `${infer First}${infer Rest}` ? `${Lowercase<First>}${Rest}` : Lowercase<S>;
|
||||
export type CamelCased<T extends Record<string, any>> = {
|
||||
[ 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>;
|
||||
import { Accessor, createEffect, createSignal, on } from "solid-js";
|
||||
|
||||
export const splitAt = (
|
||||
subject: string,
|
||||
index: number,
|
||||
): readonly [string, string] => {
|
||||
if (index < 0) {
|
||||
return [subject, ""];
|
||||
}
|
||||
|
||||
if (index > subject.length) {
|
||||
return [subject, ""];
|
||||
}
|
||||
|
||||
return [subject.slice(0, index), subject.slice(index + 1)];
|
||||
};
|
||||
|
||||
export const toSlug = (subject: string) =>
|
||||
subject.toLowerCase().replaceAll(" ", "-").replaceAll(/[^\w-]/gi, "");
|
||||
export const toHex = (subject: number) => subject.toString(16).padStart(2, "0");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
export const hash = (
|
||||
algorithm: AlgorithmIdentifier,
|
||||
subject: Accessor<string | null | undefined>,
|
||||
) => {
|
||||
const [hash, setHash] = createSignal<string>();
|
||||
|
||||
createEffect(
|
||||
on(subject, async (subject) => {
|
||||
if (subject === null || subject === undefined || subject.length === 0) {
|
||||
setHash(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(
|
||||
await crypto.subtle.digest(algorithm, encoder.encode(subject)),
|
||||
);
|
||||
|
||||
setHash(Array.from(buffer).map(toHex).join(""));
|
||||
}),
|
||||
);
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
export const merge = (...objects: Record<string, any>[]): Record<string, any> => {
|
||||
if (objects.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const target = objects[0];
|
||||
|
||||
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]);
|
||||
|
||||
target[key] = values.every(v => v && typeof v === 'object' && !Array.isArray(v)) ? merge(...values) : values.at(-1);
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
type CamelCase<S extends string> = S extends `${infer First}${infer Rest}` ? `${Lowercase<First>}${Rest}` : Lowercase<S>;
|
||||
export type CamelCased<T extends Record<string, any>> = {
|
||||
[ 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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue