mirror of
https://codeberg.org/Freedium-cfd/web.git
synced 2026-03-11 09:04:37 +00:00
fix(new-freedium): bug fixes, settings restructure, minor UI fixes, overlay loader implement
This commit is contained in:
parent
286691ea87
commit
39b539a31c
17 changed files with 392 additions and 206 deletions
|
|
@ -1,4 +1,12 @@
|
||||||
from freedium_library.__init__ import __VERSION__
|
from freedium_library.__init__ import __VERSION__
|
||||||
|
from freedium_library.api.container import APIContainer
|
||||||
|
|
||||||
__NAME__ = "Freedium Library API"
|
__NAME__ = "Freedium Library API"
|
||||||
|
|
||||||
|
api_container = APIContainer()
|
||||||
|
api_container.wire(
|
||||||
|
modules=["freedium_library.api.settings", "freedium_library.api.main"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["__NAME__", "__VERSION__"]
|
__all__ = ["__NAME__", "__VERSION__"]
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,23 @@ from freedium_library.api.settings import ApplicationSettings
|
||||||
|
|
||||||
|
|
||||||
def create_application() -> FastAPI:
|
def create_application() -> FastAPI:
|
||||||
container = APIContainer()
|
api_container = APIContainer()
|
||||||
container.wire(modules=["freedium_library.api.settings"])
|
|
||||||
|
|
||||||
settings = ApplicationSettings(container=container)
|
settings = ApplicationSettings(container=api_container)
|
||||||
config = container.config()
|
config = api_container.config()
|
||||||
|
|
||||||
if config.DISABLED_DOCS:
|
if config.DISABLED_DOCS:
|
||||||
logger.warning(f"Documentation is disabled: {config.DISABLED_DOCS}")
|
logger.warning(f"Documentation is disabled: {config.DISABLED_DOCS}")
|
||||||
settings.disable_docs()
|
settings.disable_docs()
|
||||||
|
|
||||||
app = FastAPI(**settings.to_dict(), lifespan=lifespan) # type: ignore
|
app = FastAPI(
|
||||||
|
title=settings.title,
|
||||||
|
version=settings.version,
|
||||||
|
openapi_url=settings.openapi_url,
|
||||||
|
docs_url=settings.docs_url,
|
||||||
|
redoc_url=settings.redoc_url,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
register_router(app, settings.prefix_path)
|
register_router(app, settings.prefix_path)
|
||||||
register_error_handler(app)
|
register_error_handler(app)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,25 @@
|
||||||
|
# freedium-library/src/freedium_library/api/config.py
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from freedium_library.utils.meta.pydantic import BaseConfig, BaseSettingsConfigDict
|
from freedium_library.utils.meta.pydantic import BaseConfig, BaseSettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfig(BaseConfig):
|
||||||
|
model_config = BaseSettingsConfigDict(env_prefix="SERVER_")
|
||||||
|
|
||||||
|
host: str = Field(default="0.0.0.0")
|
||||||
|
port: int = Field(default=7080)
|
||||||
|
reload: bool = Field(default=False)
|
||||||
|
workers: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class APIConfig(BaseConfig):
|
class APIConfig(BaseConfig):
|
||||||
model_config = BaseSettingsConfigDict(env_prefix="API_")
|
model_config = BaseSettingsConfigDict(env_prefix="API_")
|
||||||
|
|
||||||
DISABLED_DOCS: bool = Field(default=False)
|
DISABLED_DOCS: bool = Field(default=False)
|
||||||
PREFIX_PATH: str = Field(default="/api")
|
PREFIX_PATH: str = Field(default="/api")
|
||||||
|
HOST: str = Field(default="0.0.0.0")
|
||||||
|
PORT: int = Field(default=7080)
|
||||||
|
MAX_WORKERS: int = Field(default=10)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from dependency_injector import containers, providers
|
from dependency_injector import containers, providers
|
||||||
|
|
||||||
from freedium_library.api.config import APIConfig
|
from freedium_library.api.config import APIConfig, ServerConfig
|
||||||
|
|
||||||
|
|
||||||
class APIContainer(containers.DeclarativeContainer):
|
class APIContainer(containers.DeclarativeContainer):
|
||||||
config = providers.Singleton(APIConfig)
|
config = providers.Singleton(APIConfig)
|
||||||
|
server_config = providers.Singleton(ServerConfig)
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,52 @@ import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from dependency_injector.wiring import Provide, inject
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from freedium_library.api.config import APIConfig, ServerConfig
|
||||||
|
from freedium_library.api.container import APIContainer
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_workers(
|
||||||
|
requested_workers: Optional[int] = None, max_workers: int = 10
|
||||||
|
) -> int:
|
||||||
|
if requested_workers is not None:
|
||||||
|
return requested_workers
|
||||||
|
|
||||||
|
cpu_count = os.cpu_count() or 1
|
||||||
|
workers = min(cpu_count + 1, max_workers)
|
||||||
|
|
||||||
|
if workers == 1:
|
||||||
|
logger.warning("Only one worker, this is not recommended for production.")
|
||||||
|
elif workers == max_workers:
|
||||||
|
logger.warning(
|
||||||
|
f"Using hardcoded maximum workers ({max_workers}), consider passing a lower number."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"Using {workers} workers based on CPU count.")
|
||||||
|
|
||||||
|
return workers
|
||||||
|
|
||||||
|
|
||||||
|
@inject
|
||||||
def start_server(
|
def start_server(
|
||||||
host: str = "0.0.0.0",
|
server_config: Optional[ServerConfig] = Provide[APIContainer.server_config],
|
||||||
port: int = 7080,
|
config: APIConfig = Provide[APIContainer.config],
|
||||||
reload: bool = False,
|
|
||||||
workers: Optional[int] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if workers is None:
|
if server_config is None:
|
||||||
workers = (os.cpu_count() or 1) + 1
|
logger.warning("No server config provided, using defaults.")
|
||||||
logger.warning(f"No workers specified, using CPU count {workers}")
|
server_config = ServerConfig(
|
||||||
|
host=config.HOST,
|
||||||
|
port=config.PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
workers = calculate_workers(server_config.workers, config.MAX_WORKERS)
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"freedium_library.api.app:app",
|
"freedium_library.api.app:app",
|
||||||
host=host,
|
host=server_config.host,
|
||||||
port=port,
|
port=server_config.port,
|
||||||
reload=reload,
|
reload=server_config.reload,
|
||||||
workers=workers,
|
workers=workers,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,13 @@ from freedium_library.api.container import APIContainer
|
||||||
|
|
||||||
|
|
||||||
class ApplicationSettings(BaseModel):
|
class ApplicationSettings(BaseModel):
|
||||||
title: str = Field(default=f"{__NAME__} API Service")
|
title: str = Field(default=f"{__NAME__} API Service", frozen=True)
|
||||||
version: str = Field(default=__VERSION__)
|
version: str = Field(default=__VERSION__, frozen=True)
|
||||||
prefix_path: str = Field(default="/api")
|
prefix_path: str = Field(default="/api")
|
||||||
openapi_url: Optional[str] = None
|
openapi_url: Optional[str] = None
|
||||||
docs_url: Optional[str] = None
|
docs_url: Optional[str] = None
|
||||||
redoc_url: Optional[str] = None
|
redoc_url: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"arbitrary_types_allowed": True}
|
|
||||||
|
|
||||||
@inject
|
@inject
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -41,6 +39,3 @@ class ApplicationSettings(BaseModel):
|
||||||
self.openapi_url = None
|
self.openapi_url = None
|
||||||
self.docs_url = None
|
self.docs_url = None
|
||||||
self.redoc_url = None
|
self.redoc_url = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, str | None]:
|
|
||||||
return self.model_dump()
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from freedium_library.api.config import ServerConfig
|
||||||
|
from freedium_library.api.main import start_server
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("--host", default="0.0.0.0", help="Host address to bind to")
|
@click.option("--host", default="0.0.0.0", help="Host address to bind to")
|
||||||
|
|
@ -11,6 +14,9 @@ import click
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
def cli(host: str, port: int, hot_reload: bool):
|
def cli(host: str, port: int, hot_reload: bool):
|
||||||
from freedium_library.api.main import start_server
|
server_config = ServerConfig(
|
||||||
|
host=host,
|
||||||
start_server(host=host, port=port, reload=hot_reload)
|
port=port,
|
||||||
|
reload=hot_reload,
|
||||||
|
)
|
||||||
|
start_server(server_config=server_config)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2,6 +2,7 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body,
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
import DrawerOverlay from "./drawer-overlay.svelte";
|
import DrawerOverlay from './drawer-overlay.svelte';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = DrawerPrimitive.ContentProps;
|
type $$Props = DrawerPrimitive.ContentProps;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border",
|
'bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
|
<div class="bg-primary mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
|
||||||
<slot />
|
<slot />
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPrimitive.Portal>
|
</DrawerPrimitive.Portal>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = DrawerPrimitive.OverlayProps;
|
type $$Props = DrawerPrimitive.OverlayProps;
|
||||||
|
|
||||||
export let el: $$Props["el"] = undefined;
|
export let el: $$Props['el'] = undefined;
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DrawerPrimitive.Overlay
|
<DrawerPrimitive.Overlay
|
||||||
bind:el
|
bind:el
|
||||||
class={cn("fixed inset-0 z-50 bg-black/80", className)}
|
class={cn('fixed inset-0 z-50 bg-black/80', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
import Header from '$lib/elements/Header.svelte';
|
import Header from '$lib/elements/Header.svelte';
|
||||||
import { formatDate } from '$lib/utils/dateFormatter';
|
import { formatDate } from '$lib/utils/dateFormatter';
|
||||||
import ImageZoom from '$lib/elements/ImageZoom.svelte';
|
import ImageZoom from '$lib/elements/ImageZoom.svelte';
|
||||||
|
|
@ -10,118 +11,174 @@
|
||||||
|
|
||||||
$: ({ article, content, loading } = data);
|
$: ({ article, content, loading } = data);
|
||||||
$: contentLoaded = !loading && !!content;
|
$: contentLoaded = !loading && !!content;
|
||||||
|
$: error = data.error;
|
||||||
|
$: showSkeleton = !error && !contentLoaded;
|
||||||
|
|
||||||
|
function getErrorMessage(error) {
|
||||||
|
if (!error) return '';
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case 'ARTICLE_NOT_FOUND':
|
||||||
|
return "We couldn't find the article you're looking for.";
|
||||||
|
case 'RENDER_ERROR':
|
||||||
|
return 'There was a problem preparing this article.';
|
||||||
|
case 'COMPILE_ERROR':
|
||||||
|
return 'There was a problem processing the article content.';
|
||||||
|
default:
|
||||||
|
return error.message || 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{article.title} - Freedium</title>
|
<title>{error ? 'Freedium' : data?.article?.title || 'Freedium'} - Freedium</title>
|
||||||
<meta name="description" content="Read about the latest updates to UploadThing" />
|
<meta name="description" content="Read about the latest updates to UploadThing" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
<div class="flex flex-col min-h-screen">
|
||||||
<main class="max-w-5xl px-4 py-8 mx-auto">
|
<main class="flex-1 w-full h-full max-w-5xl px-4 py-8">
|
||||||
<nav class="flex items-center justify-between gap-2 mb-4 text-center">
|
<nav class="flex items-center justify-between gap-2 mb-4 text-center">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="flex items-center justify-center transition bg-white rounded-full shadow-md text-primary hover:text-primary/90 group size-8 shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20"
|
class="flex items-center justify-center transition bg-white rounded-full shadow-md text-primary hover:text-primary/90 group size-8 shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20"
|
||||||
>
|
>
|
||||||
<span class="icon-[heroicons--arrow-left-20-solid] size-6" />
|
<span class="icon-[heroicons--arrow-left-20-solid] size-6" />
|
||||||
</a>
|
</a>
|
||||||
<a href="/" class="font-bold text-primary hover:text-primary/90"> Original article</a>
|
{#if article?.url}
|
||||||
</nav>
|
<a href={article.url} class="font-bold text-primary hover:text-primary/90">
|
||||||
|
Original article
|
||||||
<div class="lg:flex lg:space-x-8">
|
</a>
|
||||||
<article class="flex-grow overflow-hidden bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
|
||||||
{#if !contentLoaded}
|
|
||||||
<Skeleton class="w-full h-96" />
|
|
||||||
<div class="p-6 bg-gray-50 dark:bg-zinc-800">
|
|
||||||
<Skeleton class="w-32 h-4 mb-2" />
|
|
||||||
<Skeleton class="w-full h-10 mb-4" />
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Skeleton class="w-12 h-12 mr-4 rounded-full" />
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton class="w-40 h-4" />
|
|
||||||
<Skeleton class="w-32 h-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<Skeleton class="w-full h-4" />
|
|
||||||
<Skeleton class="w-full h-4" />
|
|
||||||
<Skeleton class="w-3/4 h-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#if article.postImage}
|
|
||||||
<ImageZoom
|
|
||||||
src={article.postImage}
|
|
||||||
alt="Post cover image"
|
|
||||||
class="object-cover w-full h-auto min-h-96"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<header class="p-6 bg-gray-50 dark:bg-zinc-800">
|
|
||||||
<p class="mb-2 text-gray-600 dark:text-gray-400">{formatDate(article.date)}</p>
|
|
||||||
<h1 class="mb-4 text-4xl font-bold text-gray-900 dark:text-white">{article.title}</h1>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<img src={article.author.avatar} alt="" class="w-12 h-12 mr-4 rounded-full" />
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-gray-900 dark:text-white">{article.author.name}</p>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">{article.author.role}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="p-6 {article.postImage ? '' : 'pt-0'} dark:text-gray-300">
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
{#if content}
|
|
||||||
{@html content}
|
|
||||||
{:else}
|
|
||||||
<p>Error loading content</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</nav>
|
||||||
|
|
||||||
<aside class="order-first mt-7 lg:mt-0 lg:min-w-80 lg:order-none">
|
{#if error}
|
||||||
{#if !contentLoaded}
|
<div class="p-6 text-center bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
||||||
<div class="w-full p-4 bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
<h1
|
||||||
<Skeleton class="w-32 h-6 mb-4" />
|
class="mb-4 text-2xl font-bold {error.status === 404 ? 'text-amber-600' : 'text-red-600'}"
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton class="w-full h-4" />
|
|
||||||
<Skeleton class="w-full h-4" />
|
|
||||||
<Skeleton class="w-3/4 h-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<nav
|
|
||||||
aria-labelledby="toc-heading"
|
|
||||||
class="w-full p-4 bg-white rounded-lg shadow-lg dark:bg-zinc-900 lg:sticky lg:top-36"
|
|
||||||
>
|
>
|
||||||
<h2 id="toc-heading" class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">
|
{error.status === 404 ? 'Article Not Found' : 'Error Loading Article'}
|
||||||
Contents
|
</h1>
|
||||||
</h2>
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
{#if article.tableOfContents && article.tableOfContents.length > 0}
|
{getErrorMessage(error)}
|
||||||
<ul class="space-y-2">
|
</p>
|
||||||
{#each article.tableOfContents as item}
|
{#if error.details && process.env.NODE_ENV === 'development'}
|
||||||
<li>
|
<pre class="p-4 mt-4 overflow-auto text-sm bg-gray-100 dark:bg-zinc-800">
|
||||||
<a
|
{error.details}
|
||||||
href={`#${item.id}`}
|
</pre>
|
||||||
class="transition-colors text-zinc-800 hover:text-zinc-900 dark:text-gray-300 dark:hover:text-white"
|
{/if}
|
||||||
>
|
<div class="mt-6 space-x-4">
|
||||||
{item.title}
|
<a
|
||||||
</a>
|
href="/"
|
||||||
</li>
|
class="inline-block px-4 py-2 text-white rounded-md bg-primary hover:bg-primary/90"
|
||||||
{/each}
|
>
|
||||||
</ul>
|
Return Home
|
||||||
{:else}
|
</a>
|
||||||
<p class="dark:text-gray-300">No table of contents available</p>
|
<button
|
||||||
{/if}
|
class="inline-block px-4 py-2 border rounded-md border-primary text-primary hover:bg-primary/10"
|
||||||
</nav>
|
on:click={() => window.location.reload()}
|
||||||
{/if}
|
>
|
||||||
</aside>
|
Try Again
|
||||||
</div>
|
</button>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if showSkeleton}
|
||||||
|
<div class="lg:flex lg:space-x-8">
|
||||||
|
<article class="flex-grow overflow-hidden bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
||||||
|
<Skeleton class="w-full h-96" />
|
||||||
|
<div class="p-6 bg-gray-50 dark:bg-zinc-800">
|
||||||
|
<Skeleton class="w-32 h-4 mb-2" />
|
||||||
|
<Skeleton class="w-full h-10 mb-4" />
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Skeleton class="w-12 h-12 mr-4 rounded-full" />
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Skeleton class="w-40 h-4" />
|
||||||
|
<Skeleton class="w-32 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Skeleton class="w-full h-4" />
|
||||||
|
<Skeleton class="w-full h-4" />
|
||||||
|
<Skeleton class="w-3/4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<Footer />
|
<aside class="order-first mt-7 lg:mt-0 lg:min-w-80 lg:order-none">
|
||||||
|
<div class="w-full p-4 bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
||||||
|
<Skeleton class="w-32 h-6 mb-4" />
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Skeleton class="w-full h-4" />
|
||||||
|
<Skeleton class="w-full h-4" />
|
||||||
|
<Skeleton class="w-3/4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="lg:flex lg:space-x-8">
|
||||||
|
<article class="flex-grow overflow-hidden bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
||||||
|
{#if article.postImage}
|
||||||
|
<ImageZoom
|
||||||
|
src={article.postImage}
|
||||||
|
alt="Post cover image"
|
||||||
|
class="object-cover w-full h-auto min-h-96"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<header class="p-6 bg-gray-50 dark:bg-zinc-800">
|
||||||
|
<p class="mb-2 text-gray-600 dark:text-gray-400">{formatDate(article.date)}</p>
|
||||||
|
<h1 class="mb-4 text-4xl font-bold text-gray-900 dark:text-white">{article.title}</h1>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img src={article.author.avatar} alt="" class="w-12 h-12 mr-4 rounded-full" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-white">{article.author.name}</p>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">{article.author.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6 {article.postImage ? '' : 'pt-0'} dark:text-gray-300">
|
||||||
|
<div class="prose max-w-none">
|
||||||
|
{#if content}
|
||||||
|
{@html content}
|
||||||
|
{:else}
|
||||||
|
<p>Error loading content</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside class="order-first mt-7 lg:mt-0 lg:min-w-80 lg:order-none">
|
||||||
|
<nav
|
||||||
|
aria-labelledby="toc-heading"
|
||||||
|
class="w-full p-4 bg-white rounded-lg shadow-lg dark:bg-zinc-900 lg:sticky lg:top-36"
|
||||||
|
>
|
||||||
|
<h2 id="toc-heading" class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Contents
|
||||||
|
</h2>
|
||||||
|
{#if article.tableOfContents && article.tableOfContents.length > 0}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each article.tableOfContents as item}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
class="transition-colors text-zinc-800 hover:text-zinc-900 dark:text-gray-300 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="dark:text-gray-300">No table of contents available</p>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
|
||||||
24
new-web/src/lib/elements/ProgressOverlay.svelte
Normal file
24
new-web/src/lib/elements/ProgressOverlay.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { navigating } from '$app/stores';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const MIN_DURATION = 400;
|
||||||
|
const isVisible = writable(false);
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if ($navigating) {
|
||||||
|
$isVisible = true;
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
$isVisible = false;
|
||||||
|
}, MIN_DURATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $isVisible}
|
||||||
|
<div class="fixed inset-0 z-40 bg-black/30 dark:bg-black/40" transition:fade={{ duration: 300 }}>
|
||||||
|
<div class="fixed z-50 top-4 right-4"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -102,62 +102,66 @@
|
||||||
</div>
|
</div>
|
||||||
</Drawer.Trigger>
|
</Drawer.Trigger>
|
||||||
<form on:submit|preventDefault={handleSubmit}>
|
<form on:submit|preventDefault={handleSubmit}>
|
||||||
<Drawer.Content>
|
<Drawer.Content class="max-h-[90dvh] flex flex-col">
|
||||||
<Drawer.Header class="text-left">
|
<div class="flex-1 mb-5 overflow-y-auto">
|
||||||
<Drawer.Title class="text-lg font-semibold tracking-tight">Report a Problem</Drawer.Title>
|
<Drawer.Header class="text-left">
|
||||||
<Drawer.Description class="text-foreground-alt">
|
<Drawer.Title class="text-lg font-semibold tracking-tight"
|
||||||
Please describe the problem you're experiencing. We'll look into it as soon as possible.
|
>Report a Problem</Drawer.Title
|
||||||
</Drawer.Description>
|
>
|
||||||
</Drawer.Header>
|
<Drawer.Description class="text-foreground-alt">
|
||||||
<div class="px-4">
|
Please describe the problem you're experiencing. We'll look into it as soon as
|
||||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 pt-7">
|
possible.
|
||||||
<div class="flex flex-col items-start gap-2">
|
</Drawer.Description>
|
||||||
<Label.Root class="text-sm font-medium">Problem Type</Label.Root>
|
</Drawer.Header>
|
||||||
<RadioGroup.Root bind:value={problemType} class="space-y-2">
|
<div class="px-4 space-y-4">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="space-y-4">
|
||||||
<RadioGroup.Item value="ui_problem" id="ui-problem-mobile" />
|
<div class="flex flex-col items-start space-y-2">
|
||||||
<Label.Root for="ui-problem-mobile">UI problem</Label.Root>
|
<Label.Root class="text-sm font-medium">Problem Type</Label.Root>
|
||||||
</div>
|
<RadioGroup.Root bind:value={problemType} class="space-y-2">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="article_not_full" id="article-not-full-mobile" />
|
<RadioGroup.Item value="ui_problem" id="ui-problem-mobile" />
|
||||||
<Label.Root for="article-not-full-mobile">Article is not full</Label.Root>
|
<Label.Root for="ui-problem-mobile">UI problem</Label.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="suggestion" id="suggestion-mobile" />
|
<RadioGroup.Item value="article_not_full" id="article-not-full-mobile" />
|
||||||
<Label.Root for="suggestion-mobile">Suggestion</Label.Root>
|
<Label.Root for="article-not-full-mobile">Article is not full</Label.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="vulnerability" id="vulnerability-mobile" />
|
<RadioGroup.Item value="suggestion" id="suggestion-mobile" />
|
||||||
<Label.Root for="vulnerability-mobile">Vulnerability (XSS, etc.)</Label.Root>
|
<Label.Root for="suggestion-mobile">Suggestion</Label.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="other" id="other-mobile" />
|
<RadioGroup.Item value="vulnerability" id="vulnerability-mobile" />
|
||||||
<Label.Root for="other-mobile">Other</Label.Root>
|
<Label.Root for="vulnerability-mobile">Vulnerability (XSS, etc.)</Label.Root>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item value="other" id="other-mobile" />
|
||||||
|
<Label.Root for="other-mobile">Other</Label.Root>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-start space-y-2">
|
||||||
|
<Label.Root for="problemDescription-mobile" class="text-sm font-medium"
|
||||||
|
>Problem Description</Label.Root
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="problemDescription-mobile"
|
||||||
|
bind:value={problemDescription}
|
||||||
|
placeholder="Describe the problem you're experiencing..."
|
||||||
|
rows={12}
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-foreground-alt">
|
||||||
|
The current opened page will be automatically attached to your report.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1">
|
|
||||||
<Label.Root for="problemDescription-mobile" class="text-sm font-medium"
|
<div class="flex justify-center gap-2">
|
||||||
>Problem Description</Label.Root
|
<Drawer.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Drawer.Close>
|
||||||
>
|
<Button type="submit">Submit</Button>
|
||||||
<Textarea
|
|
||||||
id="problemDescription-mobile"
|
|
||||||
bind:value={problemDescription}
|
|
||||||
placeholder="Describe the problem you're experiencing..."
|
|
||||||
rows={12}
|
|
||||||
/>
|
|
||||||
<p class="mt-2 text-sm text-foreground-alt">
|
|
||||||
The current opened page will be automatically attached to your report.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Drawer.Footer class="p-4">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Drawer.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Drawer.Close>
|
|
||||||
<Button type="submit">Submit</Button>
|
|
||||||
</div>
|
|
||||||
</Drawer.Footer>
|
|
||||||
</Drawer.Content>
|
</Drawer.Content>
|
||||||
</form>
|
</form>
|
||||||
</Drawer.Root>
|
</Drawer.Root>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { Toaster } from '$lib/components/ui/sonner';
|
import { Toaster } from '$lib/components/ui/sonner';
|
||||||
|
import ProgressOverlay from '$lib/elements/ProgressOverlay.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<ProgressOverlay />
|
||||||
<Toaster position="top-right" expand={true} />
|
<Toaster position="top-right" expand={true} />
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,16 @@ const CODE_ATTRIBUTES = {
|
||||||
"data-ms-editor": "false",
|
"data-ms-editor": "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let highlighterInstance = null;
|
||||||
|
|
||||||
|
async function getHighlighter() {
|
||||||
|
if (!highlighterInstance) {
|
||||||
|
highlighterInstance = await createHighlighter(HIGHLIGHT_CONFIG);
|
||||||
|
await highlighterInstance.loadLanguage("javascript", "typescript");
|
||||||
|
}
|
||||||
|
return highlighterInstance;
|
||||||
|
}
|
||||||
|
|
||||||
const MOCK_ARTICLE = {
|
const MOCK_ARTICLE = {
|
||||||
title: "UploadThing is 5x Faster",
|
title: "UploadThing is 5x Faster",
|
||||||
date: "2024-09-13T12:00:00Z",
|
date: "2024-09-13T12:00:00Z",
|
||||||
|
|
@ -75,8 +85,7 @@ function createCodeCopyButton(code, toggleMs = 3000) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createHighlightedCode(code, lang = "text") {
|
async function createHighlightedCode(code, lang = "text") {
|
||||||
const highlighter = await createHighlighter(HIGHLIGHT_CONFIG);
|
const highlighter = await getHighlighter();
|
||||||
await highlighter.loadLanguage("javascript", "typescript");
|
|
||||||
|
|
||||||
const lightHtml = highlighter.codeToHtml(code, {
|
const lightHtml = highlighter.codeToHtml(code, {
|
||||||
lang,
|
lang,
|
||||||
|
|
@ -115,9 +124,36 @@ async function createHighlightedCode(code, lang = "text") {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ErrorCodes = {
|
||||||
|
ARTICLE_NOT_FOUND: "ARTICLE_NOT_FOUND",
|
||||||
|
RENDER_ERROR: "RENDER_ERROR",
|
||||||
|
COMPILE_ERROR: "COMPILE_ERROR",
|
||||||
|
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||||||
|
};
|
||||||
|
|
||||||
export async function load({ params }) {
|
export async function load({ params }) {
|
||||||
|
let transformed = null;
|
||||||
|
try {
|
||||||
|
transformed = await render("medium");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to render article:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformed) {
|
||||||
|
return {
|
||||||
|
slug: params.slug,
|
||||||
|
loading: false,
|
||||||
|
content: null,
|
||||||
|
article: null,
|
||||||
|
error: {
|
||||||
|
status: 404,
|
||||||
|
message: "Article not found",
|
||||||
|
code: ErrorCodes.ARTICLE_NOT_FOUND,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transformed = await render("medium");
|
|
||||||
const { code } = await compile(transformed.text, {
|
const { code } = await compile(transformed.text, {
|
||||||
highlight: {
|
highlight: {
|
||||||
highlighter: createHighlightedCode,
|
highlighter: createHighlightedCode,
|
||||||
|
|
@ -129,21 +165,23 @@ export async function load({ params }) {
|
||||||
loading: false,
|
loading: false,
|
||||||
content: code,
|
content: code,
|
||||||
article: MOCK_ARTICLE,
|
article: MOCK_ARTICLE,
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (compileError) {
|
||||||
console.error("Failed to load article:", error);
|
|
||||||
return {
|
return {
|
||||||
slug: params.slug,
|
slug: params.slug,
|
||||||
loading: false,
|
loading: false,
|
||||||
content: null,
|
content: null,
|
||||||
article: {
|
article: null,
|
||||||
title: "Article Not Found",
|
error: {
|
||||||
date: new Date().toISOString(),
|
status: 500,
|
||||||
author: { name: "Unknown", role: "", avatar: "" },
|
message: "Failed to compile article content",
|
||||||
postImage: null,
|
code: ErrorCodes.COMPILE_ERROR,
|
||||||
tableOfContents: [],
|
details:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? compileError.message
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
error: error.message,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ url_for_test = {
|
||||||
"https://www.freedium.cfd/c81e00f6320d",
|
"https://www.freedium.cfd/c81e00f6320d",
|
||||||
}
|
}
|
||||||
|
|
||||||
blacklist_url = {"51e23c5a2aac"}
|
blacklist_url = {"51e23c5a2aac", "e65e737baa29"}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue