fix: resolve lint errors from ACL/admin implementation
- Remove unused requireOwnerOrAdmin import from videos.ts - Remove unused requireAuth import from users.ts - Remove unused GraphQLError import from articles.ts - Replace URLSearchParams with SvelteURLSearchParams in admin users page - Apply prettier formatting to all changed files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
CLAUDE.md
16
CLAUDE.md
@@ -75,14 +75,14 @@ Points + achievements system tracked in `user_points` and `user_stats` tables. L
|
||||
|
||||
## Environment Variables (Backend)
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `REDIS_URL` | Redis connection string |
|
||||
| `COOKIE_SECRET` | Session cookie signing |
|
||||
| `CORS_ORIGIN` | Frontend origin URL |
|
||||
| `UPLOAD_DIR` | File storage path |
|
||||
| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) |
|
||||
| Variable | Purpose |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `REDIS_URL` | Redis connection string |
|
||||
| `COOKIE_SECRET` | Session cookie signing |
|
||||
| `CORS_ORIGIN` | Frontend origin URL |
|
||||
| `UPLOAD_DIR` | File storage path |
|
||||
| `SMTP_HOST/PORT/EMAIL_FROM` | Email (Nodemailer) |
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import { ArticleType } from "../types/index";
|
||||
import { articles, users } from "../../db/schema/index";
|
||||
@@ -80,10 +79,7 @@ builder.queryField("adminListArticles", (t) =>
|
||||
type: [ArticleType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const articleList = await ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.orderBy(desc(articles.publish_date));
|
||||
const articleList = await ctx.db.select().from(articles).orderBy(desc(articles.publish_date));
|
||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -87,11 +87,7 @@ builder.mutationField("deleteComment", (t) =>
|
||||
id: t.arg.int({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const comment = await ctx.db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(eq(comments.id, args.id))
|
||||
.limit(1);
|
||||
const comment = await ctx.db.select().from(comments).where(eq(comments.id, args.id)).limit(1);
|
||||
if (!comment[0]) throw new GraphQLError("Comment not found");
|
||||
requireOwnerOrAdmin(ctx, comment[0].user_id);
|
||||
await ctx.db.delete(comments).where(eq(comments.id, args.id));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { builder } from "../builder";
|
||||
import { CurrentUserType, UserType, AdminUserListType } from "../types/index";
|
||||
import { users } from "../../db/schema/index";
|
||||
import { eq, ilike, or, count, and } from "drizzle-orm";
|
||||
import { requireAuth, requireRole } from "../../lib/acl";
|
||||
import { requireRole } from "../../lib/acl";
|
||||
|
||||
builder.queryField("me", (t) =>
|
||||
t.field({
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
files,
|
||||
} from "../../db/schema/index";
|
||||
import { eq, and, lte, desc, inArray, count } from "drizzle-orm";
|
||||
import { requireRole, requireOwnerOrAdmin } from "../../lib/acl";
|
||||
import { requireRole } from "../../lib/acl";
|
||||
|
||||
async function enrichVideo(db: any, video: any) {
|
||||
// Fetch models
|
||||
@@ -433,10 +433,7 @@ builder.queryField("adminListVideos", (t) =>
|
||||
type: [VideoType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(videos)
|
||||
.orderBy(desc(videos.upload_date));
|
||||
const rows = await ctx.db.select().from(videos).orderBy(desc(videos.upload_date));
|
||||
return Promise.all(rows.map((v: any) => enrichVideo(ctx.db, v)));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -229,13 +229,11 @@ export const VideoPlayResponseType = builder
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoLikeStatusType = builder
|
||||
.objectRef<VideoLikeStatus>("VideoLikeStatus")
|
||||
.implement({
|
||||
fields: (t) => ({
|
||||
liked: t.exposeBoolean("liked"),
|
||||
}),
|
||||
});
|
||||
export const VideoLikeStatusType = builder.objectRef<VideoLikeStatus>("VideoLikeStatus").implement({
|
||||
fields: (t) => ({
|
||||
liked: t.exposeBoolean("liked"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const VideoAnalyticsType = builder.objectRef<VideoAnalytics>("VideoAnalytics").implement({
|
||||
fields: (t) => ({
|
||||
|
||||
@@ -26,7 +26,17 @@ function createLogger(bindings: Record<string, unknown> = {}, initialLevel: LogL
|
||||
message = arg;
|
||||
} else if (arg !== null && typeof arg === "object") {
|
||||
// Pino-style: log(obj, msg?) — strip internal pino keys
|
||||
const { msg: m, level: _l, time: _t, pid: _p, hostname: _h, req: _req, res: _res, reqId, ...rest } = arg as Record<string, unknown>;
|
||||
const {
|
||||
msg: m,
|
||||
level: _l,
|
||||
time: _t,
|
||||
pid: _p,
|
||||
hostname: _h,
|
||||
req: _req,
|
||||
res: _res,
|
||||
reqId,
|
||||
...rest
|
||||
} = arg as Record<string, unknown>;
|
||||
message = msg || (typeof m === "string" ? m : "");
|
||||
if (reqId) meta.reqId = reqId;
|
||||
Object.assign(meta, rest);
|
||||
|
||||
@@ -90,7 +90,10 @@
|
||||
{/each}
|
||||
{#if (recording.device_info?.length ?? 0) > 2}
|
||||
<div class="text-xs text-muted-foreground/60 px-2">
|
||||
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ?? 0) - 2 > 1
|
||||
+{(recording.device_info?.length ?? 0) - 2} more device{(recording.device_info?.length ??
|
||||
0) -
|
||||
2 >
|
||||
1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
|
||||
<div class="flex min-h-screen bg-background">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-56 shrink-0 border-r border-border/40 bg-card/60 backdrop-blur-sm flex flex-col"
|
||||
>
|
||||
<aside class="w-56 shrink-0 border-r border-border/40 bg-card/60 backdrop-blur-sm flex flex-col">
|
||||
<div class="px-4 py-5 border-b border-border/40">
|
||||
<a href="/" class="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
← Back to site
|
||||
|
||||
@@ -21,14 +21,12 @@
|
||||
let tags = $state<string[]>(data.article.tags ?? []);
|
||||
let featured = $state(data.article.featured ?? false);
|
||||
let publishDate = $state(
|
||||
data.article.publish_date
|
||||
? new Date(data.article.publish_date).toISOString().slice(0, 16)
|
||||
: "",
|
||||
data.article.publish_date ? new Date(data.article.publish_date).toISOString().slice(0, 16) : "",
|
||||
);
|
||||
let imageId = $state<string | null>(data.article.image ?? null);
|
||||
let saving = $state(false);
|
||||
|
||||
let preview = $derived(content ? marked.parse(content) as string : "");
|
||||
let preview = $derived(content ? (marked.parse(content) as string) : "");
|
||||
|
||||
async function handleImageUpload(files: File[]) {
|
||||
const file = files[0];
|
||||
@@ -98,10 +96,7 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label>Content (Markdown)</Label>
|
||||
<div class="grid grid-cols-2 gap-4 min-h-96">
|
||||
<Textarea
|
||||
bind:value={content}
|
||||
class="h-full min-h-96 font-mono text-sm resize-none"
|
||||
/>
|
||||
<Textarea bind:value={content} class="h-full min-h-96 font-mono text-sm resize-none" />
|
||||
<div
|
||||
class="rounded-lg border border-border/40 bg-muted/20 p-4 overflow-auto prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground"
|
||||
>
|
||||
@@ -117,13 +112,13 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
{#if imageId}
|
||||
<img src={getAssetUrl(imageId, "thumbnail")} alt="" class="h-24 rounded object-cover mb-2" />
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleImageUpload}
|
||||
/>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let imageId = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
let preview = $derived(content ? marked.parse(content) as string : "");
|
||||
let preview = $derived(content ? (marked.parse(content) as string) : "");
|
||||
|
||||
function generateSlug(t: string) {
|
||||
return t
|
||||
@@ -87,7 +87,9 @@
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
oninput={() => { if (!slug) slug = generateSlug(title); }}
|
||||
oninput={() => {
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder="Article title"
|
||||
/>
|
||||
</div>
|
||||
@@ -125,11 +127,7 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleImageUpload}
|
||||
/>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { SvelteURLSearchParams } from "svelte/reactivity";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { adminUpdateUser, adminDeleteUser } from "$lib/services";
|
||||
import { getAssetUrl } from "$lib/api";
|
||||
@@ -25,7 +26,7 @@
|
||||
function debounceSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
if (value) params.set("search", value);
|
||||
else params.delete("search");
|
||||
params.delete("offset");
|
||||
@@ -34,7 +35,7 @@
|
||||
}
|
||||
|
||||
function setRole(role: string) {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
if (role) params.set("role", role);
|
||||
else params.delete("role");
|
||||
params.delete("offset");
|
||||
@@ -193,7 +194,7 @@
|
||||
variant="outline"
|
||||
disabled={data.offset === 0}
|
||||
onclick={() => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(Math.max(0, data.offset - data.limit)));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
@@ -205,7 +206,7 @@
|
||||
variant="outline"
|
||||
disabled={data.offset + data.limit >= data.total}
|
||||
onclick={() => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
|
||||
params.set("offset", String(data.offset + data.limit));
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
@@ -230,11 +231,7 @@
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
@@ -87,8 +87,7 @@
|
||||
>
|
||||
{/if}
|
||||
{#if video.featured}
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
|
||||
>Featured</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
let premium = $state(data.video.premium ?? false);
|
||||
let featured = $state(data.video.featured ?? false);
|
||||
let uploadDate = $state(
|
||||
data.video.upload_date
|
||||
? new Date(data.video.upload_date).toISOString().slice(0, 16)
|
||||
: "",
|
||||
data.video.upload_date ? new Date(data.video.upload_date).toISOString().slice(0, 16) : "",
|
||||
);
|
||||
let imageId = $state<string | null>(data.video.image ?? null);
|
||||
let movieId = $state<string | null>(data.video.movie ?? null);
|
||||
@@ -118,13 +116,13 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
{#if imageId}
|
||||
<img src={getAssetUrl(imageId, "thumbnail")} alt="" class="h-24 rounded object-cover mb-2" />
|
||||
<img
|
||||
src={getAssetUrl(imageId, "thumbnail")}
|
||||
alt=""
|
||||
class="h-24 rounded object-cover mb-2"
|
||||
/>
|
||||
{/if}
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleImageUpload}
|
||||
/>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
@@ -132,11 +130,7 @@
|
||||
{#if movieId}
|
||||
<p class="text-xs text-muted-foreground mb-1">Current file: {movieId}</p>
|
||||
{/if}
|
||||
<FileDropZone
|
||||
accept="video/*"
|
||||
maxFileSize={2000 * MEGABYTE}
|
||||
onUpload={handleVideoUpload}
|
||||
/>
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
|
||||
@@ -110,7 +110,9 @@
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
oninput={() => { if (!slug) slug = generateSlug(title); }}
|
||||
oninput={() => {
|
||||
if (!slug) slug = generateSlug(title);
|
||||
}}
|
||||
placeholder="Video title"
|
||||
/>
|
||||
</div>
|
||||
@@ -122,26 +124,23 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea id="description" bind:value={description} placeholder="Optional description" rows={3} />
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Cover image</Label>
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
maxFileSize={10 * MEGABYTE}
|
||||
onUpload={handleImageUpload}
|
||||
/>
|
||||
<FileDropZone accept="image/*" maxFileSize={10 * MEGABYTE} onUpload={handleImageUpload} />
|
||||
{#if imageId}<p class="text-xs text-green-600 mt-1">Image uploaded ✓</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Video file</Label>
|
||||
<FileDropZone
|
||||
accept="video/*"
|
||||
maxFileSize={2000 * MEGABYTE}
|
||||
onUpload={handleVideoUpload}
|
||||
/>
|
||||
<FileDropZone accept="video/*" maxFileSize={2000 * MEGABYTE} onUpload={handleVideoUpload} />
|
||||
{#if movieId}<p class="text-xs text-green-600 mt-1">Video uploaded ✓</p>{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,37 +141,37 @@
|
||||
|
||||
<!-- Author Bio -->
|
||||
{#if data.article.author}
|
||||
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src={getAssetUrl(data.article.author.avatar, "mini")}
|
||||
alt={data.article.author.first_name}
|
||||
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-2">
|
||||
About {data.article.author.first_name}
|
||||
</h3>
|
||||
{#if data.article.author.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.article.author.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.article.author.website}
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a
|
||||
href={"https://" + data.article.author.website}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{data.article.author.website}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
<Card class="p-0 bg-gradient-to-r from-card/50 to-card">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src={getAssetUrl(data.article.author.avatar, "mini")}
|
||||
alt={data.article.author.first_name}
|
||||
class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-2">
|
||||
About {data.article.author.first_name}
|
||||
</h3>
|
||||
{#if data.article.author.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.article.author.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.article.author.website}
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a
|
||||
href={"https://" + data.article.author.website}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{data.article.author.website}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ export const GET = async () => {
|
||||
excludeRoutePatterns: ["^/signup/verify", "^/password/reset", "^/me", "^/play", "^/tags/.+"],
|
||||
paramValues: {
|
||||
"/magazine/[slug]": (await getArticles(fetch)).map((a) => a.slug),
|
||||
"/models/[slug]": (await getModels(fetch)).map((a) => a.slug).filter((s): s is string => s !== null),
|
||||
"/models/[slug]": (await getModels(fetch))
|
||||
.map((a) => a.slug)
|
||||
.filter((s): s is string => s !== null),
|
||||
"/videos/[slug]": (await getVideos(fetch)).map((a) => a.slug),
|
||||
},
|
||||
defaultChangefreq: "always",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<Meta
|
||||
title={displayName}
|
||||
description={data.user.description || `${displayName}'s profile`}
|
||||
image={data.user.avatar ? getAssetUrl(data.user.avatar, "thumbnail") ?? undefined : undefined}
|
||||
image={data.user.avatar ? (getAssetUrl(data.user.avatar, "thumbnail") ?? undefined) : undefined}
|
||||
/>
|
||||
|
||||
<div class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5">
|
||||
@@ -91,8 +91,6 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{#if data.user.description}
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{data.user.description}
|
||||
@@ -183,7 +181,7 @@
|
||||
{$_("gamification.achievements")} ({data.gamification.achievements.length})
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{#each (data.gamification?.achievements ?? []) as achievement (achievement.id)}
|
||||
{#each data.gamification?.achievements ?? [] as achievement (achievement.id)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-accent/10 border border-border/30 hover:border-primary/50 transition-colors"
|
||||
title={achievement.description}
|
||||
@@ -194,7 +192,9 @@
|
||||
</span>
|
||||
{#if achievement.date_unlocked}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{new Date(achievement.date_unlocked).toLocaleDateString($locale ?? undefined)}
|
||||
{new Date(achievement.date_unlocked).toLocaleDateString(
|
||||
$locale ?? undefined,
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user