Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf0528730 | ||
|
|
428b57732e | ||
|
|
61e77e3e28 | ||
|
|
b363c151a5 |
@@ -2,20 +2,14 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main", "master"]
|
|
||||||
paths:
|
paths:
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
- "ui/**"
|
- "ui/**"
|
||||||
- "caddy/**"
|
|
||||||
- "docker-compose.yml"
|
|
||||||
- ".gitea/workflows/ci.yaml"
|
- ".gitea/workflows/ci.yaml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main", "master"]
|
|
||||||
paths:
|
paths:
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
- "ui/**"
|
- "ui/**"
|
||||||
- "caddy/**"
|
|
||||||
- "docker-compose.yml"
|
|
||||||
- ".gitea/workflows/ci.yaml"
|
- ".gitea/workflows/ci.yaml"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -23,10 +17,13 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── backend: vet & test ───────────────────────────────────────────────────────
|
# ── Go: vet + build + test ────────────────────────────────────────────────
|
||||||
test-backend:
|
backend:
|
||||||
name: Test backend
|
name: Backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -36,16 +33,23 @@ jobs:
|
|||||||
cache-dependency-path: backend/go.sum
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
- name: go vet
|
- name: go vet
|
||||||
working-directory: backend
|
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: go build -o /dev/null ./cmd/backend
|
||||||
|
|
||||||
|
- name: Build runner
|
||||||
|
run: go build -o /dev/null ./cmd/runner
|
||||||
|
|
||||||
|
- name: Build healthcheck
|
||||||
|
run: go build -o /dev/null ./cmd/healthcheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: backend
|
|
||||||
run: go test -short -race -count=1 -timeout=60s ./...
|
run: go test -short -race -count=1 -timeout=60s ./...
|
||||||
|
|
||||||
# ── ui: type-check & build ────────────────────────────────────────────────────
|
# ── UI: type-check + build ────────────────────────────────────────────────
|
||||||
check-ui:
|
ui:
|
||||||
name: Check ui
|
name: UI
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -67,57 +71,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
# ── docker: validate Dockerfiles build (no push) ──────────────────────────────
|
|
||||||
docker-backend:
|
|
||||||
name: Docker / backend
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test-backend]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: backend
|
|
||||||
target: backend
|
|
||||||
push: false
|
|
||||||
|
|
||||||
docker-runner:
|
|
||||||
name: Docker / runner
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test-backend]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: backend
|
|
||||||
target: runner
|
|
||||||
push: false
|
|
||||||
|
|
||||||
docker-ui:
|
|
||||||
name: Docker / ui
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [check-ui]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ui
|
|
||||||
push: false
|
|
||||||
|
|
||||||
docker-caddy:
|
|
||||||
name: Docker / caddy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: caddy
|
|
||||||
push: false
|
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||||
BUILD_COMMIT=${{ gitea.sha }}
|
BUILD_COMMIT=${{ gitea.sha }}
|
||||||
|
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
|
||||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ COPY . .
|
|||||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
ARG BUILD_COMMIT=unknown
|
ARG BUILD_COMMIT=unknown
|
||||||
|
ARG BUILD_TIME=unknown
|
||||||
|
|
||||||
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
||||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||||
|
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -40,5 +42,16 @@ ENV NODE_ENV=production
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Carry build-time metadata into the runtime image so the UI footer can
|
||||||
|
# display the version, commit SHA, and build timestamp.
|
||||||
|
# These must be re-declared after the second FROM — ARG values do not
|
||||||
|
# cross stage boundaries, but ENV values set here persist at runtime.
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
|
ARG BUILD_COMMIT=unknown
|
||||||
|
ARG BUILD_TIME=unknown
|
||||||
|
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||||
|
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||||
|
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||||
|
|
||||||
EXPOSE $PORT
|
EXPOSE $PORT
|
||||||
CMD ["node", "build"]
|
CMD ["node", "build"]
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
|
* Returns a presigned GET URL for a user's avatar from MinIO.
|
||||||
* Returns null if no avatar exists.
|
* Returns null if no avatar object exists in MinIO for this user.
|
||||||
*/
|
*/
|
||||||
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||||
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
|
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||||
@@ -54,6 +54,42 @@ export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
|||||||
return data.url ? rewriteHost(data.url) : null;
|
return data.url ? rewriteHost(data.url) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the best available avatar URL for a user.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. MinIO — if the user has uploaded a custom avatar it will be found here
|
||||||
|
* (presigned, short-lived GET URL).
|
||||||
|
* 2. OAuth provider URL — stored in avatar_url when the account was created
|
||||||
|
* via Google / GitHub OAuth (e.g. https://lh3.googleusercontent.com/...).
|
||||||
|
* Returned as-is; the browser fetches it directly.
|
||||||
|
*
|
||||||
|
* Pass the raw `avatar_url` field from the PocketBase record as `storedValue`
|
||||||
|
* so this function can distinguish between a MinIO key and a remote URL without
|
||||||
|
* an extra DB round-trip.
|
||||||
|
*
|
||||||
|
* Returns null when neither source yields an avatar.
|
||||||
|
*/
|
||||||
|
export async function resolveAvatarUrl(
|
||||||
|
userId: string,
|
||||||
|
storedValue: string | null | undefined
|
||||||
|
): Promise<string | null> {
|
||||||
|
// 1. Try MinIO first (custom upload takes priority over OAuth picture).
|
||||||
|
try {
|
||||||
|
const minioUrl = await presignAvatarUrl(userId);
|
||||||
|
if (minioUrl) return minioUrl;
|
||||||
|
} catch {
|
||||||
|
// MinIO unavailable — fall through to OAuth fallback.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to OAuth-provided picture URL if it looks like a remote URL.
|
||||||
|
if (storedValue && storedValue.startsWith('http')) {
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -541,6 +541,19 @@ export async function getUserByUsername(username: string): Promise<User | null>
|
|||||||
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
|
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a user by their PocketBase record ID. Returns null if not found.
|
||||||
|
*/
|
||||||
|
export async function getUserById(id: string): Promise<User | null> {
|
||||||
|
const token = await getToken();
|
||||||
|
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json() as Promise<User>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a user by email. Returns null if not found.
|
* Look up a user by email. Returns null if not found.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -433,17 +433,26 @@
|
|||||||
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
||||||
<span>© {new Date().getFullYear()} libnovel</span>
|
<span>© {new Date().getFullYear()} libnovel</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Build version / commit SHA -->
|
<!-- Build version / commit SHA / build time -->
|
||||||
<div class="text-zinc-700 tabular-nums font-mono">
|
{#snippet buildTime()}
|
||||||
|
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
|
||||||
|
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
|
||||||
|
<span class="text-zinc-500" title="Build time">
|
||||||
|
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<div class="text-xs tabular-nums font-mono px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700">
|
||||||
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
||||||
<span title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
|
<span class="text-zinc-300" title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
|
||||||
{#if env.PUBLIC_BUILD_COMMIT && env.PUBLIC_BUILD_COMMIT !== 'unknown'}
|
{#if env.PUBLIC_BUILD_COMMIT && env.PUBLIC_BUILD_COMMIT !== 'unknown'}
|
||||||
<span class="text-zinc-800 select-all" title="Commit SHA"
|
<span class="text-zinc-500 select-all" title="Commit SHA"
|
||||||
>+{env.PUBLIC_BUILD_COMMIT.slice(0, 7)}</span
|
>+{env.PUBLIC_BUILD_COMMIT.slice(0, 7)}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
{@render buildTime()}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-zinc-800">dev</span>
|
<span class="text-zinc-400">dev</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getUserByUsername } from '$lib/server/pocketbase';
|
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||||
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/me
|
* GET /api/auth/me
|
||||||
@@ -13,10 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
}
|
}
|
||||||
// Fetch full record from PocketBase to get avatar_url
|
// Fetch full record from PocketBase to get avatar_url
|
||||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||||
|
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
|
||||||
return json({
|
return json({
|
||||||
id: locals.user.id,
|
id: locals.user.id,
|
||||||
username: locals.user.username,
|
username: locals.user.username,
|
||||||
role: locals.user.role,
|
role: locals.user.role,
|
||||||
avatar_url: record?.avatar_url ?? null
|
avatar_url: avatarUrl
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
listReplies,
|
listReplies,
|
||||||
createComment,
|
createComment,
|
||||||
getMyVotes,
|
getMyVotes,
|
||||||
|
getUserById,
|
||||||
type CommentSort
|
type CommentSort
|
||||||
} from '$lib/server/pocketbase';
|
} from '$lib/server/pocketbase';
|
||||||
import { presignAvatarUrl } from '$lib/server/minio';
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,13 +39,15 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
|||||||
replies: repliesPerComment[i]
|
replies: repliesPerComment[i]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Batch-resolve avatar presign URLs for all unique user_ids
|
// Batch-resolve avatar URLs for all unique user_ids
|
||||||
|
// MinIO first (custom upload), fall back to OAuth provider picture.
|
||||||
const allComments = [...topLevel, ...allReplies];
|
const allComments = [...topLevel, ...allReplies];
|
||||||
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
||||||
const avatarEntries = await Promise.all(
|
const avatarEntries = await Promise.all(
|
||||||
uniqueUserIds.map(async (userId) => {
|
uniqueUserIds.map(async (userId) => {
|
||||||
try {
|
try {
|
||||||
const url = await presignAvatarUrl(userId);
|
const user = await getUserById(userId);
|
||||||
|
const url = await resolveAvatarUrl(userId, user?.avatar_url);
|
||||||
return [userId, url] as [string, string | null];
|
return [userId, url] as [string, string | null];
|
||||||
} catch {
|
} catch {
|
||||||
return [userId, null] as [string, null];
|
return [userId, null] as [string, null];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { presignAvatarUrl } from '$lib/server/minio';
|
import { presignAvatarUrl, resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
||||||
import { backendFetch } from '$lib/server/scraper';
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
|
||||||
@@ -63,10 +63,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
if (!locals.user) error(401, 'Not authenticated');
|
if (!locals.user) error(401, 'Not authenticated');
|
||||||
|
|
||||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||||
if (!record?.avatar_url) {
|
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
|
||||||
return json({ avatar_url: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
|
||||||
return json({ avatar_url: avatarUrl });
|
return json({ avatar_url: avatarUrl });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
|
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
|
||||||
import { presignAvatarUrl } from '$lib/server/minio';
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,11 +15,9 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
|||||||
const profile = await getPublicProfile(username);
|
const profile = await getPublicProfile(username);
|
||||||
if (!profile) error(404, `User "${username}" not found`);
|
if (!profile) error(404, `User "${username}" not found`);
|
||||||
|
|
||||||
// Resolve avatar presigned URL if set
|
// Resolve avatar — MinIO first, fall back to OAuth provider picture
|
||||||
let avatarUrl: string | null = null;
|
let avatarUrl: string | null = null;
|
||||||
if (profile.avatar_url) {
|
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
|
||||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is the current logged-in user subscribed?
|
// Is the current logged-in user subscribed?
|
||||||
let isSubscribed = false;
|
let isSubscribed = false;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { navigating } from '$app/state';
|
import { navigating } from '$app/state';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
@@ -7,6 +8,29 @@
|
|||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
// ── Local filter state (mirrors URL params) ──────────────────────────────
|
||||||
|
// These are separate from data.* so we can bind them to selects and keep
|
||||||
|
// the DOM in sync. They sync back from data whenever a navigation completes.
|
||||||
|
let filterSort = $state(untrack(() => data.sort));
|
||||||
|
let filterGenre = $state(untrack(() => data.genre));
|
||||||
|
let filterStatus = $state(untrack(() => data.status));
|
||||||
|
|
||||||
|
// Keep local state in sync whenever SvelteKit re-runs the load (URL changed).
|
||||||
|
$effect(() => {
|
||||||
|
filterSort = data.sort;
|
||||||
|
filterGenre = data.genre;
|
||||||
|
filterStatus = data.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigateWithFilters(overrides: { sort?: string; genre?: string; status?: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('sort', overrides.sort ?? filterSort);
|
||||||
|
params.set('genre', overrides.genre ?? filterGenre);
|
||||||
|
params.set('status', overrides.status ?? filterStatus);
|
||||||
|
params.set('page', '1');
|
||||||
|
goto(`/catalogue?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Track which novel card is currently being navigated to
|
// Track which novel card is currently being navigated to
|
||||||
let loadingSlug = $state<string | null>(null);
|
let loadingSlug = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -389,11 +413,12 @@
|
|||||||
<select
|
<select
|
||||||
id="filter-sort"
|
id="filter-sort"
|
||||||
name="sort"
|
name="sort"
|
||||||
value={data.sort}
|
bind:value={filterSort}
|
||||||
|
onchange={() => navigateWithFilters({ sort: filterSort })}
|
||||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
|
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
|
||||||
>
|
>
|
||||||
{#each sorts as s}
|
{#each sorts as s}
|
||||||
<option value={s.value}>{s.label}</option>
|
<option value={s.value} selected={s.value === filterSort}>{s.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,12 +428,13 @@
|
|||||||
<select
|
<select
|
||||||
id="filter-genre"
|
id="filter-genre"
|
||||||
name="genre"
|
name="genre"
|
||||||
value={data.genre}
|
bind:value={filterGenre}
|
||||||
|
onchange={() => navigateWithFilters({ genre: filterGenre })}
|
||||||
disabled={isRankView}
|
disabled={isRankView}
|
||||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||||
>
|
>
|
||||||
{#each genres as g}
|
{#each genres as g}
|
||||||
<option value={g.value}>{g.label}</option>
|
<option value={g.value} selected={g.value === filterGenre}>{g.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,12 +444,13 @@
|
|||||||
<select
|
<select
|
||||||
id="filter-status"
|
id="filter-status"
|
||||||
name="status"
|
name="status"
|
||||||
value={data.status}
|
bind:value={filterStatus}
|
||||||
|
onchange={() => navigateWithFilters({ status: filterStatus })}
|
||||||
disabled={isRankView}
|
disabled={isRankView}
|
||||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||||
>
|
>
|
||||||
{#each statuses as st}
|
{#each statuses as st}
|
||||||
<option value={st.value}>{st.label}</option>
|
<option value={st.value} selected={st.value === filterStatus}>{st.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,13 +464,6 @@
|
|||||||
<a href="/catalogue" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
|
<a href="/catalogue" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
|
||||||
Reset
|
Reset
|
||||||
</a>
|
</a>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
onclick={() => (filtersOpen = false)}
|
|
||||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||||
import { presignAvatarUrl } from '$lib/server/minio';
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
@@ -16,13 +16,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch avatar presigned URL if user has one
|
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||||
let avatarUrl: string | null = null;
|
let avatarUrl: string | null = null;
|
||||||
try {
|
try {
|
||||||
const record = await getUserByUsername(locals.user.username);
|
const record = await getUserByUsername(locals.user.username);
|
||||||
if (record?.avatar_url) {
|
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
||||||
avatarUrl = await presignAvatarUrl(locals.user.id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getUserPublicLibrary,
|
getUserPublicLibrary,
|
||||||
getUserCurrentlyReading
|
getUserCurrentlyReading
|
||||||
} from '$lib/server/pocketbase';
|
} from '$lib/server/pocketbase';
|
||||||
import { presignAvatarUrl } from '$lib/server/minio';
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
@@ -15,11 +15,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
const profile = await getPublicProfile(username).catch(() => null);
|
const profile = await getPublicProfile(username).catch(() => null);
|
||||||
if (!profile) error(404, `User "${username}" not found`);
|
if (!profile) error(404, `User "${username}" not found`);
|
||||||
|
|
||||||
// Resolve avatar
|
// Resolve avatar — MinIO first, fall back to OAuth provider picture
|
||||||
let avatarUrl: string | null = null;
|
let avatarUrl: string | null = null;
|
||||||
if (profile.avatar_url) {
|
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
|
||||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription state for the logged-in visitor
|
// Subscription state for the logged-in visitor
|
||||||
let isSubscribed = false;
|
let isSubscribed = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user