Merge commit '5125b46f0' into release/v2

This commit is contained in:
Thomas Camlong committed 2026-08-10 16:26:09 +02:00
commit 2b32ece92d
18 files changed
+71 -85

No files matched your search

+2 -2
View File
@@ -95,8 +95,8 @@ irreversible development reset.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
links and email use the public host. Workshop stores only the OAuth provider username in `users.githubUsername`, and
users cannot rename it. Map **OAuth2 username** to `githubUsername`; leave OAuth2 id, full name, and avatar mappings
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and
users cannot rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
SMTP for comment, report, and removal notices.
@@ -159,12 +159,10 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
const canDelete = ownComment || currentUser?.isAdmin === true;
const isEditing = editingId === comment.id;
const edited = comment.updated !== comment.created;
const authorGithubUsername = ownComment
? (currentUser?.githubUsername ?? comment.authorGithubUsername)
: comment.authorGithubUsername;
const authorName = authorGithubUsername || "Community member";
const avatarUrl = githubAvatarUrl(authorGithubUsername);
const profileUrl = githubProfileUrl(authorGithubUsername);
const authorHandle = ownComment ? (currentUser?.name ?? comment.authorName) : comment.authorName;
const authorName = authorHandle || "Community member";
const avatarUrl = githubAvatarUrl(authorHandle);
const profileUrl = githubProfileUrl(authorHandle);
return (
<article
@@ -292,10 +290,8 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2.5">
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
{currentUser?.githubUsername && <AvatarImage src={githubAvatarUrl(currentUser.githubUsername)} alt="" />}
<AvatarFallback className="bg-background">
{avatarFallback(currentUser?.githubUsername ?? "Guest")}
</AvatarFallback>
{currentUser?.name && <AvatarImage src={githubAvatarUrl(currentUser.name)} alt="" />}
<AvatarFallback className="bg-background">{avatarFallback(currentUser?.name ?? "Guest")}</AvatarFallback>
</Avatar>
<Textarea
className="min-h-16 flex-1 resize-none bg-background"
@@ -526,10 +526,10 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const canManage = user?.id === submission.author || user?.isAdmin === true;
const canEdit = user?.id === submission.author;
const signedInAuthor = user?.id === submission.author ? user : null;
const authorGithubUsername = signedInAuthor?.githubUsername || submission.authorGithubUsername;
const authorName = authorGithubUsername || "Community member";
const authorAvatarUrl = githubAvatarUrl(authorGithubUsername);
const authorGithubProfileUrl = githubProfileUrl(authorGithubUsername);
const authorHandle = signedInAuthor?.name || submission.authorName;
const authorName = authorHandle || "Community member";
const authorAvatarUrl = githubAvatarUrl(authorHandle);
const authorGithubProfileUrl = githubProfileUrl(authorHandle);
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
@@ -18,9 +18,9 @@ interface WorkshopAccountMenuProps {
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user.githubUsername);
const avatarUrl = githubAvatarUrl(user.githubUsername);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : "Account";
const profileUrl = githubProfileUrl(user.name);
const avatarUrl = githubAvatarUrl(user.name);
const accountLabel = user.name ? `@${user.name}` : "Account";
useEffect(() => {
if (!open) return;
@@ -53,7 +53,7 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
>
<Avatar className="size-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.githubUsername)}</AvatarFallback>
<AvatarFallback className="text-[10px]">{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
@@ -72,7 +72,7 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.githubUsername)}</AvatarFallback>
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{accountLabel}</p>
@@ -195,20 +195,18 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10">
{submission.authorGithubUsername && (
<AvatarImage src={githubAvatarUrl(submission.authorGithubUsername)} alt="" />
)}
<AvatarFallback>{avatarFallback(submission.authorGithubUsername)}</AvatarFallback>
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={githubProfileUrl(submission.authorGithubUsername) || undefined}
target={submission.authorGithubUsername ? "_blank" : undefined}
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorGithubUsername || "Community member"}
{submission.authorName || "Community member"}
</a>
</div>
</div>
@@ -240,7 +238,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<div className="min-w-0">
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
<p className="m-0 mt-1 text-xs text-destructive/75">
Reported by {report.reporterGithubUsername || "Community member"} ·{" "}
Reported by {report.reporterName || "Community member"} ·{" "}
{new Date(report.created).toLocaleString()}
</p>
</div>
@@ -99,8 +99,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
.filter((item) => includeOutdated || !item.outdated)
.filter(
(item) =>
!q ||
[item.title, item.description, item.authorGithubUsername].some((value) => value?.toLowerCase().includes(q)),
!q || [item.title, item.description, item.authorName].some((value) => value?.toLowerCase().includes(q)),
)
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
@@ -358,18 +357,16 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</div>
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
<a
href={githubProfileUrl(submission.authorGithubUsername) || undefined}
target={submission.authorGithubUsername ? "_blank" : undefined}
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
>
<Avatar className="size-4">
{submission.authorGithubUsername && (
<AvatarImage src={githubAvatarUrl(submission.authorGithubUsername)} alt="" />
)}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorGithubUsername)}</AvatarFallback>
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<span className="truncate">{submission.authorGithubUsername || "Community member"}</span>
<span className="truncate">{submission.authorName || "Community member"}</span>
</a>
<span className="shrink-0 whitespace-nowrap">
· v{submission.revision} · {formatRelativeTime(submission.created)}
@@ -163,7 +163,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
</Group>
{user ? (
<Group gap="xs">
<Badge variant="light">{user.githubUsername}</Badge>
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
@@ -254,7 +254,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{t("author", { name: item.authorGithubUsername || "Community member" })}
{t("author", { name: item.authorName || "Community member" })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
@@ -307,7 +307,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Stack>
<Text>{detail.data.description}</Text>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorGithubUsername || "Community member" })}
{t("author", { name: detail.data.authorName || "Community member" })}
</Text>
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
+2 -2
View File
@@ -91,8 +91,8 @@ GITHUB_CLIENT_SECRET=...
OAuth settings synchronize on every PocketBase startup. Restart after rotation. A production deployment must never
supply only one credential. `PB_ALLOWED_ORIGINS=*` is appropriate for the central community because arbitrary
self-hosted Homarr origins must open its OAuth popup; a restricted list must include every allowed browser origin.
Workshop stores only the OAuth provider username in `users.githubUsername`; users cannot rename it. PocketBase maps
**OAuth2 username** to `githubUsername` and leaves the OAuth2 id, full name, and avatar mappings empty. GitHub profiles
Workshop stores only the OAuth provider username in `users.name`; users cannot rename it. PocketBase maps
**OAuth2 username** to `name` and leaves the OAuth2 id, full name, and avatar mappings empty. GitHub profiles
and avatars are derived from `https://github.com/<username>` and `https://github.com/<username>.png`, so no duplicate
profile metadata or avatar file is stored.
+3 -3
View File
@@ -11,7 +11,7 @@ onBootstrap((event) => {
event.next();
const users = event.app.findCollectionByNameOrId("users");
users.oauth2.enabled = configured;
users.oauth2.mappedFields = { id: "", name: "", username: "githubUsername", avatarURL: "" };
users.oauth2.mappedFields = { id: "", name: "", username: "name", avatarURL: "" };
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
event.app.save(users);
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
@@ -95,7 +95,7 @@ onRecordAfterCreateSuccess((event) => {
return;
}
const commenterName = commenter.getString("githubUsername") || "A community member";
const commenterName = commenter.getString("name") || "A community member";
const submissionTitle = submission.getString("title");
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicWorkshopUrl}/${submission.id}/`;
@@ -132,7 +132,7 @@ onRecordAfterCreateSuccess((event) => {
return;
}
const reporterName = reporter.getString("githubUsername") || "A community member";
const reporterName = reporter.getString("name") || "A community member";
const submissionTitle = submission.getString("title");
const category = event.record.getString("category");
const explanation = event.record.getString("explanation");
@@ -3,10 +3,9 @@
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const cloneRule = (value) => (value === null ? null : String(value));
const adminRule = "@request.auth.isAdmin = true";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.githubUsername:changed = false",
].join(" && ");
const identityFieldsUnchanged = ["@request.body.email:changed = false", "@request.body.name:changed = false"].join(
" && ",
);
const immutableSubmissionFields = [
"@request.body.author:changed = false",
"@request.body.type:changed = false",
@@ -64,7 +63,7 @@ migrate(
};
users.passwordAuth = { enabled: false };
addUserField(new TextField({ name: "githubUsername", required: true, min: 1, max: 39 }));
addUserField(new TextField({ name: "name", required: true, min: 1, max: 39 }));
addUserField(new BoolField({ name: "isAdmin" }));
users.listRule = "";
@@ -236,7 +235,7 @@ migrate(
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.widgetSchema, s.screenshots, s.author,
u.githubUsername AS authorGithubUsername,
u.name AS authorName,
s.revision, s.changelog, s.outdated, s.created, s.updated,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = 1), 0) AS upvotes,
COALESCE((SELECT COUNT(*) FROM votes v WHERE v.submission = s.id AND v.value = -1), 0) AS downvotes,
@@ -24,7 +24,7 @@ if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
}
if (
users.oauth2?.mappedFields?.username !== "githubUsername" ||
users.oauth2?.mappedFields?.username !== "name" ||
users.oauth2?.mappedFields?.id ||
users.oauth2?.mappedFields?.name ||
users.oauth2?.mappedFields?.avatarURL
+2 -2
View File
@@ -15,7 +15,7 @@ if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (!hook.includes('username: "githubUsername"') || hook.includes("onRecordAuthWithOAuth2Request")) {
if (!hook.includes('username: "name"') || hook.includes("onRecordAuthWithOAuth2Request")) {
throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping");
}
if (hook.includes("validateAndNormalizeSubmission")) {
@@ -41,7 +41,7 @@ if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFil
}
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl"]) {
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
}
for (const required of [
+6 -6
View File
@@ -46,7 +46,7 @@ await request("/api/collections/users", {
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
});
const createUser = async (email, githubUsername, password, extra = {}) =>
const createUser = async (email, name, password, extra = {}) =>
request("/api/collections/users/records", {
method: "POST",
headers: rootHeaders,
@@ -56,7 +56,7 @@ const createUser = async (email, githubUsername, password, extra = {}) =>
verified: true,
password,
passwordConfirm: password,
githubUsername,
name,
...extra,
}),
});
@@ -79,13 +79,13 @@ await expectStatus(
);
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ githubUsername: "homarr-labs" }) },
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ name: "homarr-labs" }) },
404,
);
const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
headers: authorSession.headers,
});
if (unchangedAuthor.githubUsername !== "widget-author") {
if (unchangedAuthor.name !== "widget-author") {
throw new Error("Workshop users can forge OAuth provider identity fields");
}
@@ -221,7 +221,7 @@ if (
listing.score !== 2 ||
listing.upvotes !== 2 ||
listing.downvotes !== 0 ||
listing.authorGithubUsername !== "widget-author" ||
listing.authorName !== "widget-author" ||
listing.widgetSchema !== widget.$schema
) {
throw new Error("Workshop listing data is incorrect");
@@ -232,7 +232,7 @@ const comment = await request("/api/collections/comments/records?expand=author",
headers: visitorSession.headers,
body: JSON.stringify({ submission: submission.id, author: visitor.id, content: "Useful widget" }),
});
if (comment.author !== visitor.id || comment.expand.author.githubUsername !== "widget-visitor") {
if (comment.author !== visitor.id || comment.expand.author.name !== "widget-visitor") {
throw new Error("Comment author expansion is incorrect");
}
await expectStatus(
@@ -96,7 +96,7 @@ export const workshopProcedures = {
id: item.id,
title: item.title,
description: item.description,
authorGithubUsername: item.authorGithubUsername,
authorName: item.authorName,
score: item.score,
revision: item.revision,
outdated: item.outdated,
@@ -119,7 +119,7 @@ export const workshopProcedures = {
id: submission.id,
title: submission.title,
description: submission.description,
authorGithubUsername: submission.authorGithubUsername,
authorName: submission.authorName,
revision: submission.revision,
score: submission.score,
outdated: submission.outdated,
+10 -13
View File
@@ -71,7 +71,7 @@ export interface WorkshopUserRecord extends WorkshopBaseRecord {
email: string;
emailVisibility: boolean;
verified: boolean;
githubUsername: string;
name: string;
isAdmin: boolean;
created: string;
updated: string;
@@ -95,7 +95,7 @@ export interface WorkshopSubmissionRecord extends WorkshopBaseRecord {
export interface WorkshopListingRecord extends Omit<WorkshopSubmissionRecord, "content" | "screenshots"> {
screenshots: string[] | string;
authorGithubUsername: string;
authorName: string;
score: number;
upvotes: number;
downvotes: number;
@@ -167,10 +167,7 @@ function filterListings(items: WorkshopSubmissionSummary[], options: WorkshopLis
if (options.type && options.type !== "all" && item.type !== options.type) return false;
if (options.author && item.author !== options.author) return false;
if (options.includeOutdated === false && item.outdated) return false;
if (
search &&
!`${item.title}\n${item.description}\n${item.authorGithubUsername}`.toLocaleLowerCase().includes(search)
)
if (search && !`${item.title}\n${item.description}\n${item.authorName}`.toLocaleLowerCase().includes(search))
return false;
return true;
}),
@@ -190,7 +187,7 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
if (options.type && options.type !== "all") filters.push(pocketBase.filter("type = {:type}", { type: options.type }));
if (options.search?.trim()) {
filters.push(
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorGithubUsername ~ {:search})", {
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorName ~ {:search})", {
search: options.search.trim(),
}),
);
@@ -215,7 +212,7 @@ const enrichAuthor = (row: unknown) => {
typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record<string, unknown>) : {};
return {
...record,
authorGithubUsername: record.authorGithubUsername || user.githubUsername,
authorName: record.authorName || user.name,
};
};
@@ -268,7 +265,7 @@ export class WorkshopBackend {
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
githubUsername: record.githubUsername || "",
name: record.name || "",
isAdmin: record.isAdmin === true,
});
}
@@ -502,7 +499,7 @@ export class WorkshopBackend {
return items.map((item) =>
workshopReportSchema.parse({
...item,
reporterGithubUsername: item.expand?.reporter?.githubUsername,
reporterName: item.expand?.reporter?.name,
submissionTitle: item.expand?.submission?.title,
}),
);
@@ -539,7 +536,7 @@ export class WorkshopBackend {
return rows.map((row) =>
workshopCommentSchema.parse({
...row,
authorGithubUsername: row.expand?.author?.githubUsername,
authorName: row.expand?.author?.name,
}),
);
} catch (error) {
@@ -554,7 +551,7 @@ export class WorkshopBackend {
.create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorGithubUsername: row.expand?.author?.githubUsername,
authorName: row.expand?.author?.name,
});
} catch (error) {
throw workshopError(error, "Failed to post comment");
@@ -568,7 +565,7 @@ export class WorkshopBackend {
.update(id, { content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorGithubUsername: row.expand?.author?.githubUsername,
authorName: row.expand?.author?.name,
});
} catch (error) {
throw workshopError(error, "Failed to update comment");
+3 -4
View File
@@ -70,7 +70,7 @@ describe("WorkshopBackend", () => {
token: "token",
record: {
id: "user-id",
githubUsername: "octocat",
name: "octocat",
},
meta: { username: "octocat" },
});
@@ -82,7 +82,7 @@ describe("WorkshopBackend", () => {
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
expect(mocks.update).not.toHaveBeenCalled();
expect(user).toMatchObject({ githubUsername: "octocat" });
expect(user).toMatchObject({ name: "octocat" });
});
test("exposes the typed submission collection service", () => {
@@ -214,8 +214,7 @@ describe("WorkshopBackend", () => {
1,
12,
expect.objectContaining({
filter:
"(title ~ 'active streams' || description ~ 'active streams' || authorGithubUsername ~ 'active streams')",
filter: "(title ~ 'active streams' || description ~ 'active streams' || authorName ~ 'active streams')",
}),
);
});
+1 -1
View File
@@ -105,7 +105,7 @@ describe("Workshop widget validation", () => {
expect(result.type).toBe("customWidget");
expect(result.screenshots).toEqual([]);
expect(result.authorGithubUsername).toBe("");
expect(result.authorName).toBe("");
expect(result.upvotes).toBe(0);
});
+4 -4
View File
@@ -90,7 +90,7 @@ const workshopFileListSchema = z
export const workshopUserSchema = z.object({
id: z.string(),
githubUsername: z.string().default(""),
name: z.string().default(""),
isAdmin: z.boolean().default(false),
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
@@ -113,7 +113,7 @@ const workshopSubmissionBaseObjectSchema = z.object({
widgetSchema: z.string(),
screenshots: workshopFileListSchema,
author: z.string(),
authorGithubUsername: z.string().default(""),
authorName: z.string().default(""),
score: z.number().int().default(0),
upvotes: z.number().int().default(0),
downvotes: z.number().int().default(0),
@@ -173,7 +173,7 @@ export const workshopReportSchema = z.object({
id: z.string(),
submission: z.string(),
reporter: z.string(),
reporterGithubUsername: z.string().default(""),
reporterName: z.string().default(""),
submissionTitle: z.string().default("Deleted submission"),
category: workshopReportCategorySchema,
explanation: z.string(),
@@ -190,7 +190,7 @@ export const workshopCommentSchema = z.object({
content: z.string().min(1).max(2_000),
created: z.string(),
updated: z.string(),
authorGithubUsername: z.string().default(""),
authorName: z.string().default(""),
});
export type WorkshopComment = z.infer<typeof workshopCommentSchema>;