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