[FA-misc] Astro migration works, probably want to touchup the frontend but that can be in Phase 4

This commit is contained in:
gamer147
2025-11-28 10:43:51 -05:00
parent bc83bffb4b
commit 8d6f0d6cfd
94 changed files with 11948 additions and 9202 deletions

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initAuth } from '$lib/auth/authStore';
onMount(() => {
initAuth();
});
</script>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { user, isLoading, isConfigured, login, logout } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
let isOpen = $state(false);
const email = $derived(
$user?.profile?.email ??
$user?.profile?.preferred_username ??
$user?.profile?.name ??
$user?.profile?.sub ??
'User'
);
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.auth-dropdown')) {
isOpen = false;
}
}
function toggleDropdown() {
isOpen = !isOpen;
}
async function handleLogout() {
isOpen = false;
await logout();
}
</script>
<svelte:window onclick={handleClickOutside} />
{#if $isLoading}
<Button variant="outline" disabled>Loading...</Button>
{:else if !isConfigured}
<span class="text-sm text-yellow-600">Auth not configured</span>
{:else if $user}
<div class="auth-dropdown relative">
<Button variant="outline" onclick={toggleDropdown}>
{email}
</Button>
{#if isOpen}
<div
class="absolute right-0 z-50 mt-2 w-48 rounded-md bg-white p-2 shadow-lg dark:bg-gray-800"
>
<Button variant="ghost" class="w-full justify-start" onclick={handleLogout}>
Sign out
</Button>
</div>
{/if}
</div>
{:else}
<Button onclick={login}>Sign in</Button>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import AuthenticationDisplay from './AuthenticationDisplay.svelte';
</script>
<header class="sticky top-0 z-10 border-b bg-white/80 backdrop-blur dark:bg-gray-900/80">
<nav class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
<a href="/" class="flex items-center gap-2">
<span class="rounded bg-primary px-2 py-1 font-bold text-white">FA</span>
<span class="font-semibold">FictionArchive</span>
</a>
<a href="/">
<Button variant="ghost">Novels</Button>
</a>
<div class="flex-1">
<Input type="search" placeholder="Search..." class="max-w-xs" />
</div>
<AuthenticationDisplay />
</nav>
</header>

View File

@@ -0,0 +1,45 @@
<script lang="ts" module>
import type { NovelsQuery } from '$lib/graphql/__generated__/graphql';
export type NovelNode = NonNullable<NonNullable<NovelsQuery['novels']>['edges']>[number]['node'];
export interface NovelCardProps {
novel: NovelNode;
}
</script>
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { novel }: NovelCardProps = $props();
function pickText(novelText?: NovelNode['name'] | NovelNode['description']) {
const texts = novelText?.texts ?? [];
const english = texts.find((t) => t.language === 'EN');
return (english ?? texts[0])?.text ?? 'No description available.';
}
const title = $derived(pickText(novel.name));
const description = $derived(pickText(novel.description));
const coverSrc = $derived(novel.coverImage?.newPath ?? novel.coverImage?.originalPath);
</script>
<Card class="overflow-hidden border shadow-sm transition-shadow hover:shadow-md">
{#if coverSrc}
<div class="aspect-[3/4] w-full overflow-hidden bg-muted/50">
<img src={coverSrc} alt={title} class="h-full w-full object-cover" loading="lazy" />
</div>
{:else}
<div class="aspect-[3/4] w-full bg-muted/50"></div>
{/if}
<CardHeader class="space-y-2">
<CardTitle class="line-clamp-2 text-lg leading-tight">
{title}
</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<p class="line-clamp-3 text-sm text-muted-foreground">
{description}
</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
interface Props {
novelId?: string;
}
let { novelId }: Props = $props();
</script>
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<CardTitle>Novel Details</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">
{#if novelId}
Viewing novel ID: <code class="rounded bg-muted px-1 py-0.5">{novelId}</code>
{/if}
</p>
<p class="mt-2 text-sm text-muted-foreground">
Detail view coming soon. Select a novel to explore chapters and metadata once implemented.
</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { NovelsDocument, type NovelsQuery } from '$lib/graphql/__generated__/graphql';
import NovelCard from './NovelCard.svelte';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
const PAGE_SIZE = 12;
type NovelEdge = NonNullable<NovelsQuery['novels']>['edges'][number];
let edges: NovelEdge[] = $state([]);
let pageInfo: NonNullable<NovelsQuery['novels']>['pageInfo'] | null = $state(null);
let fetching = $state(false);
let error: string | null = $state(null);
let initialLoad = $state(true);
const hasNextPage = $derived(pageInfo?.hasNextPage ?? false);
const novels = $derived(edges.map((edge) => edge.node).filter(Boolean));
async function fetchNovels(after: string | null = null) {
fetching = true;
error = null;
try {
const result = await client.query(NovelsDocument, { first: PAGE_SIZE, after }).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.novels) {
if (after) {
// Append for pagination
edges = [...edges, ...result.data.novels.edges];
} else {
// Initial load
edges = result.data.novels.edges;
}
pageInfo = result.data.novels.pageInfo;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
initialLoad = false;
}
}
function loadMore() {
if (pageInfo?.endCursor) {
fetchNovels(pageInfo.endCursor);
}
}
onMount(() => {
fetchNovels();
});
</script>
<div class="space-y-4">
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<CardTitle>Latest Novels</CardTitle>
<p class="text-sm text-muted-foreground">Novels that have recently been updated.</p>
</CardHeader>
</Card>
{#if fetching && initialLoad}
<Card>
<CardContent>
<div class="flex items-center justify-center py-8">
<div
class="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent"
aria-label="Loading novels"
></div>
</div>
</CardContent>
</Card>
{/if}
{#if error}
<Card class="border-destructive/40 bg-destructive/5">
<CardContent>
<p class="py-4 text-sm text-destructive">Could not load novels: {error}</p>
</CardContent>
</Card>
{/if}
{#if !fetching && novels.length === 0 && !error && !initialLoad}
<Card>
<CardContent>
<p class="py-4 text-sm text-muted-foreground">
No novels found yet. Try adding content to the gateway.
</p>
</CardContent>
</Card>
{/if}
{#if novels.length > 0}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each novels as novel (novel.id)}
<NovelCard {novel} />
{/each}
</div>
{/if}
{#if hasNextPage}
<div class="flex justify-center">
<Button onclick={loadMore} variant="outline" disabled={fetching} class="min-w-[160px]">
{fetching ? 'Loading...' : 'Load more'}
</Button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}