[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,29 @@
---
import Navbar from '../lib/components/Navbar.svelte';
import AuthInit from '../lib/components/AuthInit.svelte';
import '../styles/global.css';
interface Props {
title?: string;
}
const { title = 'FictionArchive' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="min-h-screen bg-background">
<AuthInit client:load />
<Navbar client:load />
<main class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<slot />
</main>
</body>
</html>

View File

@@ -0,0 +1,115 @@
import { writable, derived } from 'svelte/store';
import type { User } from 'oidc-client-ts';
import { userManager, isOidcConfigured } from './oidcConfig';
// Stores
export const user = writable<User | null>(null);
export const isLoading = writable(true);
export const isAuthenticated = derived(user, ($user) => $user !== null);
export const isConfigured = isOidcConfigured;
// Cookie management
function setCookieFromUser(u: User) {
if (!u?.access_token) return;
const isProduction = window.location.hostname !== 'localhost';
const domain = isProduction ? '.orfl.xyz' : undefined;
const secure = isProduction;
const sameSite = isProduction ? 'None' : 'Lax';
const cookieValue = `fa_session=${u.access_token}; path=/; ${secure ? 'secure; ' : ''}samesite=${sameSite}${domain ? `; domain=${domain}` : ''}`;
document.cookie = cookieValue;
}
function clearFaSessionCookie() {
const isProduction = window.location.hostname !== 'localhost';
const domain = isProduction ? '.orfl.xyz' : undefined;
const cookieValue = `fa_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domain ? `; domain=${domain}` : ''}`;
document.cookie = cookieValue;
}
// Track if callback has been handled to prevent double processing
let callbackHandled = false;
export async function initAuth() {
if (!userManager) {
isLoading.set(false);
return;
}
// Handle callback if auth params are present
const url = new URL(window.location.href);
const hasAuthParams =
url.searchParams.has('code') ||
url.searchParams.has('id_token') ||
url.searchParams.has('error');
if (hasAuthParams && !callbackHandled) {
callbackHandled = true;
try {
const result = await userManager.signinRedirectCallback();
user.set(result ?? null);
if (result) {
setCookieFromUser(result);
}
} catch (e) {
console.error('Failed to complete sign-in redirect', e);
} finally {
const cleanUrl = `${url.origin}${url.pathname}`;
window.history.replaceState({}, document.title, cleanUrl);
}
}
// Load existing user
try {
const loadedUser = await userManager.getUser();
user.set(loadedUser ?? null);
if (loadedUser) {
setCookieFromUser(loadedUser);
}
} catch (e) {
console.error('Failed to load user', e);
}
isLoading.set(false);
// Event listeners
userManager.events.addUserLoaded((u) => {
user.set(u);
setCookieFromUser(u);
});
userManager.events.addUserUnloaded(() => {
user.set(null);
clearFaSessionCookie();
});
userManager.events.addUserSignedOut(() => {
user.set(null);
clearFaSessionCookie();
});
}
export async function login() {
if (!userManager) {
console.warn('OIDC is not configured; set PUBLIC_OIDC_* environment variables.');
return;
}
await userManager.signinRedirect();
}
export async function logout() {
if (!userManager) {
console.warn('OIDC is not configured; set PUBLIC_OIDC_* environment variables.');
return;
}
try {
clearFaSessionCookie();
await userManager.signoutRedirect();
} catch (error) {
console.error('Failed to sign out via redirect, clearing local session instead.', error);
await userManager.removeUser();
user.set(null);
}
}

View File

@@ -0,0 +1,35 @@
import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts';
const authority = import.meta.env.PUBLIC_OIDC_AUTHORITY;
const clientId = import.meta.env.PUBLIC_OIDC_CLIENT_ID;
const redirectUri = import.meta.env.PUBLIC_OIDC_REDIRECT_URI;
const postLogoutRedirectUri = import.meta.env.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri;
const scope = import.meta.env.PUBLIC_OIDC_SCOPE ?? 'openid profile email';
export const isOidcConfigured =
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri);
function buildSettings(): UserManagerSettings | null {
if (!isOidcConfigured) return null;
return {
authority: authority!,
client_id: clientId!,
redirect_uri: redirectUri!,
post_logout_redirect_uri: postLogoutRedirectUri,
response_type: 'code',
scope,
loadUserInfo: true,
automaticSilentRenew: true,
userStore:
typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.localStorage })
: undefined,
};
}
export const userManager = (() => {
const settings = buildSettings();
if (!settings) return null;
return new UserManager(settings);
})();

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}

View File

@@ -0,0 +1,785 @@
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
Instant: { input: any; output: any; }
UUID: { input: any; output: any; }
UnsignedInt: { input: any; output: any; }
};
/** Defines when a policy shall be executed. */
export const ApplyPolicy = {
/** After the resolver was executed. */
AfterResolver: 'AFTER_RESOLVER',
/** Before the resolver was executed. */
BeforeResolver: 'BEFORE_RESOLVER',
/** The policy is applied in the validation step before the execution. */
Validation: 'VALIDATION'
} as const;
export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy];
export type Chapter = {
body: LocalizationKey;
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
images: Array<Image>;
lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey;
order: Scalars['UnsignedInt']['output'];
revision: Scalars['UnsignedInt']['output'];
url: Maybe<Scalars['String']['output']>;
};
export type ChapterFilterInput = {
and?: InputMaybe<Array<ChapterFilterInput>>;
body?: InputMaybe<LocalizationKeyFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
images?: InputMaybe<ListFilterInputTypeOfImageFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>;
or?: InputMaybe<Array<ChapterFilterInput>>;
order?: InputMaybe<UnsignedIntOperationFilterInputType>;
revision?: InputMaybe<UnsignedIntOperationFilterInputType>;
url?: InputMaybe<StringOperationFilterInput>;
};
export type ChapterPullRequestedEvent = {
chapterNumber: Scalars['UnsignedInt']['output'];
novelId: Scalars['UnsignedInt']['output'];
};
export type ChapterSortInput = {
body?: InputMaybe<LocalizationKeySortInput>;
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>;
order?: InputMaybe<SortEnumType>;
revision?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>;
};
export type DeleteJobError = KeyNotFoundError;
export type DeleteJobInput = {
jobKey: Scalars['String']['input'];
};
export type DeleteJobPayload = {
boolean: Maybe<Scalars['Boolean']['output']>;
errors: Maybe<Array<DeleteJobError>>;
};
export type DuplicateNameError = Error & {
message: Scalars['String']['output'];
};
export type Error = {
message: Scalars['String']['output'];
};
export type FetchChapterContentsInput = {
chapterNumber: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
};
export type FetchChapterContentsPayload = {
chapterPullRequestedEvent: Maybe<ChapterPullRequestedEvent>;
};
export type FormatError = Error & {
message: Scalars['String']['output'];
};
export type Image = {
chapter: Maybe<Chapter>;
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
newPath: Maybe<Scalars['String']['output']>;
originalPath: Scalars['String']['output'];
};
export type ImageFilterInput = {
and?: InputMaybe<Array<ImageFilterInput>>;
chapter?: InputMaybe<ChapterFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
newPath?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<ImageFilterInput>>;
originalPath?: InputMaybe<StringOperationFilterInput>;
};
export type ImageSortInput = {
chapter?: InputMaybe<ChapterSortInput>;
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
newPath?: InputMaybe<SortEnumType>;
originalPath?: InputMaybe<SortEnumType>;
};
export type ImportNovelInput = {
novelUrl: Scalars['String']['input'];
};
export type ImportNovelPayload = {
novelUpdateRequestedEvent: Maybe<NovelUpdateRequestedEvent>;
};
export type InstantFilterInput = {
and?: InputMaybe<Array<InstantFilterInput>>;
or?: InputMaybe<Array<InstantFilterInput>>;
};
export type JobKey = {
group: Scalars['String']['output'];
name: Scalars['String']['output'];
};
export type JobPersistenceError = Error & {
message: Scalars['String']['output'];
};
export type KeyNotFoundError = Error & {
message: Scalars['String']['output'];
};
export type KeyValuePairOfStringAndString = {
key: Scalars['String']['output'];
value: Scalars['String']['output'];
};
export const Language = {
Ch: 'CH',
En: 'EN',
Ja: 'JA',
Kr: 'KR'
} as const;
export type Language = typeof Language[keyof typeof Language];
export type LanguageOperationFilterInput = {
eq?: InputMaybe<Language>;
in?: InputMaybe<Array<Language>>;
neq?: InputMaybe<Language>;
nin?: InputMaybe<Array<Language>>;
};
export type ListFilterInputTypeOfChapterFilterInput = {
all?: InputMaybe<ChapterFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<ChapterFilterInput>;
some?: InputMaybe<ChapterFilterInput>;
};
export type ListFilterInputTypeOfImageFilterInput = {
all?: InputMaybe<ImageFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<ImageFilterInput>;
some?: InputMaybe<ImageFilterInput>;
};
export type ListFilterInputTypeOfLocalizationTextFilterInput = {
all?: InputMaybe<LocalizationTextFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<LocalizationTextFilterInput>;
some?: InputMaybe<LocalizationTextFilterInput>;
};
export type ListFilterInputTypeOfNovelFilterInput = {
all?: InputMaybe<NovelFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<NovelFilterInput>;
some?: InputMaybe<NovelFilterInput>;
};
export type ListFilterInputTypeOfNovelTagFilterInput = {
all?: InputMaybe<NovelTagFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<NovelTagFilterInput>;
some?: InputMaybe<NovelTagFilterInput>;
};
export type LocalizationKey = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
texts: Array<LocalizationText>;
};
export type LocalizationKeyFilterInput = {
and?: InputMaybe<Array<LocalizationKeyFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<LocalizationKeyFilterInput>>;
texts?: InputMaybe<ListFilterInputTypeOfLocalizationTextFilterInput>;
};
export type LocalizationKeySortInput = {
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
};
export type LocalizationText = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UUID']['output'];
language: Language;
lastUpdatedTime: Scalars['Instant']['output'];
text: Scalars['String']['output'];
translationEngine: Maybe<TranslationEngine>;
};
export type LocalizationTextFilterInput = {
and?: InputMaybe<Array<LocalizationTextFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
language?: InputMaybe<LanguageOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<LocalizationTextFilterInput>>;
text?: InputMaybe<StringOperationFilterInput>;
translationEngine?: InputMaybe<TranslationEngineFilterInput>;
};
export type Mutation = {
deleteJob: DeleteJobPayload;
fetchChapterContents: FetchChapterContentsPayload;
importNovel: ImportNovelPayload;
registerUser: RegisterUserPayload;
runJob: RunJobPayload;
scheduleEventJob: ScheduleEventJobPayload;
translateText: TranslateTextPayload;
};
export type MutationDeleteJobArgs = {
input: DeleteJobInput;
};
export type MutationFetchChapterContentsArgs = {
input: FetchChapterContentsInput;
};
export type MutationImportNovelArgs = {
input: ImportNovelInput;
};
export type MutationRegisterUserArgs = {
input: RegisterUserInput;
};
export type MutationRunJobArgs = {
input: RunJobInput;
};
export type MutationScheduleEventJobArgs = {
input: ScheduleEventJobInput;
};
export type MutationTranslateTextArgs = {
input: TranslateTextInput;
};
export type Novel = {
author: Person;
chapters: Array<Chapter>;
coverImage: Maybe<Image>;
createdTime: Scalars['Instant']['output'];
description: LocalizationKey;
externalId: Scalars['String']['output'];
id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey;
rawLanguage: Language;
rawStatus: NovelStatus;
source: Source;
statusOverride: Maybe<NovelStatus>;
tags: Array<NovelTag>;
url: Scalars['String']['output'];
};
export type NovelFilterInput = {
and?: InputMaybe<Array<NovelFilterInput>>;
author?: InputMaybe<PersonFilterInput>;
chapters?: InputMaybe<ListFilterInputTypeOfChapterFilterInput>;
coverImage?: InputMaybe<ImageFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
description?: InputMaybe<LocalizationKeyFilterInput>;
externalId?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>;
or?: InputMaybe<Array<NovelFilterInput>>;
rawLanguage?: InputMaybe<LanguageOperationFilterInput>;
rawStatus?: InputMaybe<NovelStatusOperationFilterInput>;
source?: InputMaybe<SourceFilterInput>;
statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>;
tags?: InputMaybe<ListFilterInputTypeOfNovelTagFilterInput>;
url?: InputMaybe<StringOperationFilterInput>;
};
export type NovelSortInput = {
author?: InputMaybe<PersonSortInput>;
coverImage?: InputMaybe<ImageSortInput>;
createdTime?: InputMaybe<SortEnumType>;
description?: InputMaybe<LocalizationKeySortInput>;
externalId?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>;
rawLanguage?: InputMaybe<SortEnumType>;
rawStatus?: InputMaybe<SortEnumType>;
source?: InputMaybe<SourceSortInput>;
statusOverride?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>;
};
export const NovelStatus = {
Abandoned: 'ABANDONED',
Completed: 'COMPLETED',
Hiatus: 'HIATUS',
InProgress: 'IN_PROGRESS',
Unknown: 'UNKNOWN'
} as const;
export type NovelStatus = typeof NovelStatus[keyof typeof NovelStatus];
export type NovelStatusOperationFilterInput = {
eq?: InputMaybe<NovelStatus>;
in?: InputMaybe<Array<NovelStatus>>;
neq?: InputMaybe<NovelStatus>;
nin?: InputMaybe<Array<NovelStatus>>;
};
export type NovelTag = {
createdTime: Scalars['Instant']['output'];
displayName: LocalizationKey;
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
novels: Array<Novel>;
source: Maybe<Source>;
tagType: TagType;
};
export type NovelTagFilterInput = {
and?: InputMaybe<Array<NovelTagFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
displayName?: InputMaybe<LocalizationKeyFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
novels?: InputMaybe<ListFilterInputTypeOfNovelFilterInput>;
or?: InputMaybe<Array<NovelTagFilterInput>>;
source?: InputMaybe<SourceFilterInput>;
tagType?: InputMaybe<TagTypeOperationFilterInput>;
};
export type NovelUpdateRequestedEvent = {
novelUrl: Scalars['String']['output'];
};
/** A connection to a list of items. */
export type NovelsConnection = {
/** A list of edges. */
edges: Maybe<Array<NovelsEdge>>;
/** A flattened list of the nodes. */
nodes: Maybe<Array<Novel>>;
/** Information to aid in pagination. */
pageInfo: PageInfo;
};
/** An edge in a connection. */
export type NovelsEdge = {
/** A cursor for use in pagination. */
cursor: Scalars['String']['output'];
/** The item at the end of the edge. */
node: Novel;
};
export type NullableOfNovelStatusOperationFilterInput = {
eq?: InputMaybe<NovelStatus>;
in?: InputMaybe<Array<InputMaybe<NovelStatus>>>;
neq?: InputMaybe<NovelStatus>;
nin?: InputMaybe<Array<InputMaybe<NovelStatus>>>;
};
/** Information about pagination in a connection. */
export type PageInfo = {
/** When paginating forwards, the cursor to continue. */
endCursor: Maybe<Scalars['String']['output']>;
/** Indicates whether more edges exist following the set defined by the clients arguments. */
hasNextPage: Scalars['Boolean']['output'];
/** Indicates whether more edges exist prior the set defined by the clients arguments. */
hasPreviousPage: Scalars['Boolean']['output'];
/** When paginating backwards, the cursor to continue. */
startCursor: Maybe<Scalars['String']['output']>;
};
export type Person = {
createdTime: Scalars['Instant']['output'];
externalUrl: Maybe<Scalars['String']['output']>;
id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: LocalizationKey;
};
export type PersonFilterInput = {
and?: InputMaybe<Array<PersonFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
externalUrl?: InputMaybe<StringOperationFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<LocalizationKeyFilterInput>;
or?: InputMaybe<Array<PersonFilterInput>>;
};
export type PersonSortInput = {
createdTime?: InputMaybe<SortEnumType>;
externalUrl?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<LocalizationKeySortInput>;
};
export type Query = {
jobs: Array<SchedulerJob>;
novels: Maybe<NovelsConnection>;
translationEngines: Array<TranslationEngineDescriptor>;
translationRequests: Maybe<TranslationRequestsConnection>;
users: Array<User>;
};
export type QueryNovelsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Array<NovelSortInput>>;
where?: InputMaybe<NovelFilterInput>;
};
export type QueryTranslationEnginesArgs = {
order?: InputMaybe<Array<TranslationEngineDescriptorSortInput>>;
where?: InputMaybe<TranslationEngineDescriptorFilterInput>;
};
export type QueryTranslationRequestsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
order?: InputMaybe<Array<TranslationRequestSortInput>>;
where?: InputMaybe<TranslationRequestFilterInput>;
};
export type RegisterUserInput = {
email: Scalars['String']['input'];
inviterOAuthProviderId?: InputMaybe<Scalars['String']['input']>;
oAuthProviderId: Scalars['String']['input'];
username: Scalars['String']['input'];
};
export type RegisterUserPayload = {
user: Maybe<User>;
};
export type RunJobError = JobPersistenceError;
export type RunJobInput = {
jobKey: Scalars['String']['input'];
};
export type RunJobPayload = {
boolean: Maybe<Scalars['Boolean']['output']>;
errors: Maybe<Array<RunJobError>>;
};
export type ScheduleEventJobError = DuplicateNameError | FormatError;
export type ScheduleEventJobInput = {
cronSchedule: Scalars['String']['input'];
description: Scalars['String']['input'];
eventData: Scalars['String']['input'];
eventType: Scalars['String']['input'];
key: Scalars['String']['input'];
};
export type ScheduleEventJobPayload = {
errors: Maybe<Array<ScheduleEventJobError>>;
schedulerJob: Maybe<SchedulerJob>;
};
export type SchedulerJob = {
cronSchedule: Array<Scalars['String']['output']>;
description: Scalars['String']['output'];
jobData: Array<KeyValuePairOfStringAndString>;
jobKey: JobKey;
jobTypeName: Scalars['String']['output'];
};
export const SortEnumType = {
Asc: 'ASC',
Desc: 'DESC'
} as const;
export type SortEnumType = typeof SortEnumType[keyof typeof SortEnumType];
export type Source = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: Scalars['String']['output'];
url: Scalars['String']['output'];
};
export type SourceFilterInput = {
and?: InputMaybe<Array<SourceFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<SourceFilterInput>>;
url?: InputMaybe<StringOperationFilterInput>;
};
export type SourceSortInput = {
createdTime?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
key?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
name?: InputMaybe<SortEnumType>;
url?: InputMaybe<SortEnumType>;
};
export type StringOperationFilterInput = {
and?: InputMaybe<Array<StringOperationFilterInput>>;
contains?: InputMaybe<Scalars['String']['input']>;
endsWith?: InputMaybe<Scalars['String']['input']>;
eq?: InputMaybe<Scalars['String']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
ncontains?: InputMaybe<Scalars['String']['input']>;
nendsWith?: InputMaybe<Scalars['String']['input']>;
neq?: InputMaybe<Scalars['String']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
nstartsWith?: InputMaybe<Scalars['String']['input']>;
or?: InputMaybe<Array<StringOperationFilterInput>>;
startsWith?: InputMaybe<Scalars['String']['input']>;
};
export const TagType = {
External: 'EXTERNAL',
Genre: 'GENRE',
System: 'SYSTEM',
UserDefined: 'USER_DEFINED'
} as const;
export type TagType = typeof TagType[keyof typeof TagType];
export type TagTypeOperationFilterInput = {
eq?: InputMaybe<TagType>;
in?: InputMaybe<Array<TagType>>;
neq?: InputMaybe<TagType>;
nin?: InputMaybe<Array<TagType>>;
};
export type TranslateTextInput = {
from: Language;
text: Scalars['String']['input'];
to: Language;
translationEngineKey: Scalars['String']['input'];
};
export type TranslateTextPayload = {
translationResult: Maybe<TranslationResult>;
};
export type TranslationEngine = {
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
key: Scalars['String']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
};
export type TranslationEngineDescriptor = {
displayName: Scalars['String']['output'];
key: Scalars['String']['output'];
};
export type TranslationEngineDescriptorFilterInput = {
and?: InputMaybe<Array<TranslationEngineDescriptorFilterInput>>;
displayName?: InputMaybe<StringOperationFilterInput>;
key?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<TranslationEngineDescriptorFilterInput>>;
};
export type TranslationEngineDescriptorSortInput = {
displayName?: InputMaybe<SortEnumType>;
key?: InputMaybe<SortEnumType>;
};
export type TranslationEngineFilterInput = {
and?: InputMaybe<Array<TranslationEngineFilterInput>>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
key?: InputMaybe<StringOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<TranslationEngineFilterInput>>;
};
export type TranslationRequest = {
billedCharacterCount: Scalars['UnsignedInt']['output'];
createdTime: Scalars['Instant']['output'];
from: Language;
id: Scalars['UUID']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
originalText: Scalars['String']['output'];
status: TranslationRequestStatus;
to: Language;
translatedText: Maybe<Scalars['String']['output']>;
translationEngineKey: Scalars['String']['output'];
};
export type TranslationRequestFilterInput = {
and?: InputMaybe<Array<TranslationRequestFilterInput>>;
billedCharacterCount?: InputMaybe<UnsignedIntOperationFilterInputType>;
createdTime?: InputMaybe<InstantFilterInput>;
from?: InputMaybe<LanguageOperationFilterInput>;
id?: InputMaybe<UuidOperationFilterInput>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
or?: InputMaybe<Array<TranslationRequestFilterInput>>;
originalText?: InputMaybe<StringOperationFilterInput>;
status?: InputMaybe<TranslationRequestStatusOperationFilterInput>;
to?: InputMaybe<LanguageOperationFilterInput>;
translatedText?: InputMaybe<StringOperationFilterInput>;
translationEngineKey?: InputMaybe<StringOperationFilterInput>;
};
export type TranslationRequestSortInput = {
billedCharacterCount?: InputMaybe<SortEnumType>;
createdTime?: InputMaybe<SortEnumType>;
from?: InputMaybe<SortEnumType>;
id?: InputMaybe<SortEnumType>;
lastUpdatedTime?: InputMaybe<SortEnumType>;
originalText?: InputMaybe<SortEnumType>;
status?: InputMaybe<SortEnumType>;
to?: InputMaybe<SortEnumType>;
translatedText?: InputMaybe<SortEnumType>;
translationEngineKey?: InputMaybe<SortEnumType>;
};
export const TranslationRequestStatus = {
Failed: 'FAILED',
Pending: 'PENDING',
Success: 'SUCCESS'
} as const;
export type TranslationRequestStatus = typeof TranslationRequestStatus[keyof typeof TranslationRequestStatus];
export type TranslationRequestStatusOperationFilterInput = {
eq?: InputMaybe<TranslationRequestStatus>;
in?: InputMaybe<Array<TranslationRequestStatus>>;
neq?: InputMaybe<TranslationRequestStatus>;
nin?: InputMaybe<Array<TranslationRequestStatus>>;
};
/** A connection to a list of items. */
export type TranslationRequestsConnection = {
/** A list of edges. */
edges: Maybe<Array<TranslationRequestsEdge>>;
/** A flattened list of the nodes. */
nodes: Maybe<Array<TranslationRequest>>;
/** Information to aid in pagination. */
pageInfo: PageInfo;
};
/** An edge in a connection. */
export type TranslationRequestsEdge = {
/** A cursor for use in pagination. */
cursor: Scalars['String']['output'];
/** The item at the end of the edge. */
node: TranslationRequest;
};
export type TranslationResult = {
billedCharacterCount: Scalars['UnsignedInt']['output'];
from: Language;
originalText: Scalars['String']['output'];
status: TranslationRequestStatus;
to: Language;
translatedText: Maybe<Scalars['String']['output']>;
translationEngineKey: Scalars['String']['output'];
};
export type UnsignedIntOperationFilterInputType = {
eq?: InputMaybe<Scalars['UnsignedInt']['input']>;
gt?: InputMaybe<Scalars['UnsignedInt']['input']>;
gte?: InputMaybe<Scalars['UnsignedInt']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['UnsignedInt']['input']>>>;
lt?: InputMaybe<Scalars['UnsignedInt']['input']>;
lte?: InputMaybe<Scalars['UnsignedInt']['input']>;
neq?: InputMaybe<Scalars['UnsignedInt']['input']>;
ngt?: InputMaybe<Scalars['UnsignedInt']['input']>;
ngte?: InputMaybe<Scalars['UnsignedInt']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['UnsignedInt']['input']>>>;
nlt?: InputMaybe<Scalars['UnsignedInt']['input']>;
nlte?: InputMaybe<Scalars['UnsignedInt']['input']>;
};
export type User = {
createdTime: Scalars['Instant']['output'];
disabled: Scalars['Boolean']['output'];
email: Scalars['String']['output'];
id: Scalars['UUID']['output'];
inviter: Maybe<User>;
lastUpdatedTime: Scalars['Instant']['output'];
oAuthProviderId: Scalars['String']['output'];
username: Scalars['String']['output'];
};
export type UuidOperationFilterInput = {
eq?: InputMaybe<Scalars['UUID']['input']>;
gt?: InputMaybe<Scalars['UUID']['input']>;
gte?: InputMaybe<Scalars['UUID']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['UUID']['input']>>>;
lt?: InputMaybe<Scalars['UUID']['input']>;
lte?: InputMaybe<Scalars['UUID']['input']>;
neq?: InputMaybe<Scalars['UUID']['input']>;
ngt?: InputMaybe<Scalars['UUID']['input']>;
ngte?: InputMaybe<Scalars['UUID']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['UUID']['input']>>>;
nlt?: InputMaybe<Scalars['UUID']['input']>;
nlte?: InputMaybe<Scalars['UUID']['input']>;
};
export type NovelsQueryVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
}>;
export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: { texts: Array<{ language: Language, text: string }> }, description: { texts: Array<{ language: Language, text: string }> }, coverImage: { originalPath: string, newPath: string | null } | null } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null };
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"description"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"originalPath"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<NovelsQuery, NovelsQueryVariables>;

View File

@@ -0,0 +1,21 @@
import { Client, cacheExchange, fetchExchange } from '@urql/core';
import { get } from 'svelte/store';
import { user } from '../auth/authStore';
export function createClient() {
return new Client({
url: import.meta.env.PUBLIC_GRAPHQL_URI,
exchanges: [cacheExchange, fetchExchange],
fetchOptions: () => {
const currentUser = get(user);
return {
headers: currentUser?.access_token
? { Authorization: `Bearer ${currentUser.access_token}` }
: {},
};
},
});
}
// Singleton for use in components
export const client = createClient();

View File

@@ -0,0 +1,31 @@
query Novels($first: Int, $after: String) {
novels(first: $first, after: $after) {
edges {
cursor
node {
id
url
name {
texts {
language
text
}
}
description {
texts {
language
text
}
}
coverImage {
originalPath
newPath
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

View File

@@ -0,0 +1,49 @@
import { writable } from 'svelte/store';
import type { OperationResult, TypedDocumentNode } from '@urql/core';
import { client } from './client';
export function queryStore<Data, Variables extends object>(
query: TypedDocumentNode<Data, Variables>,
variables: Variables
) {
const result = writable<OperationResult<Data> | null>(null);
const fetching = writable(true);
async function execute(vars: Variables = variables) {
fetching.set(true);
const res = await client.query(query, vars).toPromise();
result.set(res);
fetching.set(false);
return res;
}
// Initial fetch
execute();
return {
subscribe: result.subscribe,
fetching: { subscribe: fetching.subscribe },
refetch: execute,
};
}
export function mutationStore<Data, Variables extends object>(
mutation: TypedDocumentNode<Data, Variables>
) {
const result = writable<OperationResult<Data> | null>(null);
const fetching = writable(false);
async function execute(variables: Variables) {
fetching.set(true);
const res = await client.mutation(mutation, variables).toPromise();
result.set(res);
fetching.set(false);
return res;
}
return {
subscribe: result.subscribe,
fetching: { subscribe: fetching.subscribe },
execute,
};
}

View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View File

@@ -0,0 +1,23 @@
---
export const prerender = true; // Static page
import AppLayout from '../layouts/AppLayout.astro';
import { Card, CardContent, CardHeader, CardTitle } from '../lib/components/ui/card';
import { Button } from '../lib/components/ui/button';
---
<AppLayout title="Not Found - FictionArchive">
<div class="flex min-h-[50vh] items-center justify-center">
<Card class="mx-auto max-w-md text-center shadow-md shadow-primary/10">
<CardHeader>
<CardTitle class="text-4xl font-bold">404</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<p class="text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
</p>
<Button href="/" variant="default">Go back home</Button>
</CardContent>
</Card>
</div>
</AppLayout>

View File

@@ -0,0 +1,10 @@
---
export const prerender = true; // Static page
import AppLayout from '../layouts/AppLayout.astro';
import NovelsPage from '../lib/components/NovelsPage.svelte';
---
<AppLayout title="Novels - FictionArchive">
<NovelsPage client:load />
</AppLayout>

View File

@@ -0,0 +1,11 @@
---
// SSR page (default in server mode, no prerender export needed)
import AppLayout from '../../layouts/AppLayout.astro';
import NovelDetailPage from '../../lib/components/NovelDetailPage.svelte';
const { id } = Astro.params;
---
<AppLayout title="Novel Detail - FictionArchive">
<NovelDetailPage novelId={id} client:load />
</AppLayout>

View File

@@ -0,0 +1,141 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.75rem;
/* Purple/Indigo theme matching original React app */
--background: oklch(0.98 0.01 250);
--foreground: oklch(0.2 0.04 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.04 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.04 260);
--primary: oklch(0.55 0.25 270);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.02 270);
--secondary-foreground: oklch(0.2 0.04 260);
--muted: oklch(0.93 0.02 250);
--muted-foreground: oklch(0.5 0.03 250);
--accent: oklch(0.96 0.03 250);
--accent-foreground: oklch(0.2 0.04 260);
--destructive: oklch(0.6 0.24 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.92 0.02 250);
--input: oklch(0.92 0.02 250);
--ring: oklch(0.55 0.25 270);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.2 0.04 260);
--sidebar-primary: oklch(0.55 0.25 270);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.95 0.02 270);
--sidebar-accent-foreground: oklch(0.2 0.04 260);
--sidebar-border: oklch(0.92 0.02 250);
--sidebar-ring: oklch(0.55 0.25 270);
}
.dark {
--background: oklch(0.2 0.04 260);
--foreground: oklch(0.98 0.01 250);
--card: oklch(0.2 0.04 260);
--card-foreground: oklch(0.98 0.01 250);
--popover: oklch(0.2 0.04 260);
--popover-foreground: oklch(0.98 0.01 250);
--primary: oklch(0.7 0.2 270);
--primary-foreground: oklch(0.2 0.04 260);
--secondary: oklch(0.25 0.04 260);
--secondary-foreground: oklch(0.98 0.01 250);
--muted: oklch(0.25 0.04 260);
--muted-foreground: oklch(0.65 0.02 250);
--accent: oklch(0.25 0.04 260);
--accent-foreground: oklch(0.98 0.01 250);
--destructive: oklch(0.6 0.24 25);
--destructive-foreground: oklch(0.98 0.01 250);
--border: oklch(0.3 0.04 260);
--input: oklch(0.3 0.04 260);
--ring: oklch(0.7 0.2 270);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.2 0.04 260);
--sidebar-foreground: oklch(0.98 0.01 250);
--sidebar-primary: oklch(0.7 0.2 270);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.25 0.04 260);
--sidebar-accent-foreground: oklch(0.98 0.01 250);
--sidebar-border: oklch(0.3 0.04 260);
--sidebar-ring: oklch(0.7 0.2 270);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground min-h-screen antialiased;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
/* Subtle gradient background matching original React app */
background-image:
radial-gradient(circle at 20% 20%, oklch(0.95 0.05 270 / 50%), transparent 32%),
radial-gradient(circle at 80% 10%, oklch(0.95 0.05 150 / 40%), transparent 25%),
radial-gradient(circle at 50% 80%, oklch(0.95 0.05 25 / 30%), transparent 20%),
linear-gradient(180deg, oklch(1 0 0 / 90%), oklch(0.98 0.01 250 / 95%));
}
.dark body {
background-image: none;
}
/* Link styling */
a:not([class]) {
@apply text-primary font-medium hover:underline;
}
}