Merge widget Workshop identity fix into mobile
This commit is contained in:
19 files changed
+114
-362
No files matched your search
@@ -71,12 +71,7 @@ export interface WorkshopUserRecord extends WorkshopBaseRecord {
|
||||
email: string;
|
||||
emailVisibility: boolean;
|
||||
verified: boolean;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
avatar: string;
|
||||
githubUsername: string;
|
||||
githubProfileUrl: string;
|
||||
isAdmin: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
@@ -100,11 +95,7 @@ export interface WorkshopSubmissionRecord extends WorkshopBaseRecord {
|
||||
|
||||
export interface WorkshopListingRecord extends Omit<WorkshopSubmissionRecord, "content" | "screenshots"> {
|
||||
screenshots: string[] | string;
|
||||
authorName: string;
|
||||
authorAvatar: string;
|
||||
authorAvatarUrl: string;
|
||||
authorGithubUsername: string;
|
||||
authorGithubProfileUrl: string;
|
||||
score: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
@@ -176,7 +167,10 @@ 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.authorName}`.toLocaleLowerCase().includes(search))
|
||||
if (
|
||||
search &&
|
||||
!`${item.title}\n${item.description}\n${item.authorGithubUsername}`.toLocaleLowerCase().includes(search)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
@@ -196,7 +190,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} || authorName ~ {:search})", {
|
||||
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorGithubUsername ~ {:search})", {
|
||||
search: options.search.trim(),
|
||||
}),
|
||||
);
|
||||
@@ -209,10 +203,7 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
|
||||
const isViewCompatibilityError = (error: unknown) =>
|
||||
error instanceof ClientResponseError && (error.status === 400 || error.status === 404);
|
||||
|
||||
const enrichAuthor = (
|
||||
row: unknown,
|
||||
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const enrichAuthor = (row: unknown) => {
|
||||
if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
|
||||
const record = row as Record<string, unknown>;
|
||||
const expand = record.expand;
|
||||
@@ -222,27 +213,15 @@ const enrichAuthor = (
|
||||
: undefined;
|
||||
const user =
|
||||
typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record<string, unknown>) : {};
|
||||
const githubUsername = record.authorGithubUsername || user.githubUsername || user.username;
|
||||
return {
|
||||
...record,
|
||||
authorName: record.authorName || user.displayName || user.username,
|
||||
authorAvatarUrl: resolveAvatarUrl?.(record, user) || record.authorAvatarUrl || user.avatarUrl,
|
||||
authorGithubUsername: githubUsername,
|
||||
authorGithubProfileUrl:
|
||||
record.authorGithubProfileUrl ||
|
||||
user.githubProfileUrl ||
|
||||
(typeof githubUsername === "string" && githubUsername
|
||||
? `https://github.com/${encodeURIComponent(githubUsername)}`
|
||||
: undefined),
|
||||
authorGithubUsername: record.authorGithubUsername || user.githubUsername,
|
||||
};
|
||||
};
|
||||
|
||||
const parseListings = (
|
||||
rows: unknown[],
|
||||
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
|
||||
) =>
|
||||
const parseListings = (rows: unknown[]) =>
|
||||
rows.flatMap((row) => {
|
||||
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row, resolveAvatarUrl));
|
||||
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row));
|
||||
return result.success ? [result.data] : [];
|
||||
});
|
||||
|
||||
@@ -284,33 +263,12 @@ export class WorkshopBackend {
|
||||
this.pocketBase.autoCancellation(false);
|
||||
}
|
||||
|
||||
private userAvatarUrl(record: Record<string, unknown>) {
|
||||
const avatar = typeof record.avatar === "string" ? record.avatar : "";
|
||||
if (avatar) return this.pocketBase.files.getURL(record, avatar);
|
||||
return typeof record.avatarUrl === "string" ? record.avatarUrl : "";
|
||||
}
|
||||
|
||||
private listingAuthorAvatarUrl(record: Record<string, unknown>, user: Record<string, unknown>) {
|
||||
const expandedAvatarUrl = this.userAvatarUrl(user);
|
||||
if (expandedAvatarUrl) return expandedAvatarUrl;
|
||||
|
||||
const author = typeof record.author === "string" ? record.author : "";
|
||||
const avatar = typeof record.authorAvatar === "string" ? record.authorAvatar : "";
|
||||
if (!author || !avatar) return "";
|
||||
return `${this.baseUrl}/api/files/users/${encodeURIComponent(author)}/${encodeURIComponent(avatar)}`;
|
||||
}
|
||||
|
||||
public get currentUser(): WorkshopUser | null {
|
||||
const record = this.pocketBase.authStore.record;
|
||||
if (!record) return null;
|
||||
return workshopUserSchema.parse({
|
||||
id: record.id,
|
||||
displayName: record.displayName || record.name || record.username || "Community member",
|
||||
avatarUrl: this.userAvatarUrl(record),
|
||||
githubUsername: record.githubUsername || "",
|
||||
githubProfileUrl:
|
||||
record.githubProfileUrl ||
|
||||
(record.githubUsername ? `https://github.com/${encodeURIComponent(record.githubUsername)}` : ""),
|
||||
isAdmin: record.isAdmin === true,
|
||||
});
|
||||
}
|
||||
@@ -337,7 +295,6 @@ export class WorkshopBackend {
|
||||
try {
|
||||
const auth = await this.pocketBase.collection("users").authWithOAuth2({
|
||||
provider: "github",
|
||||
createData: { displayName: "GitHub user" },
|
||||
});
|
||||
this.pocketBase.authStore.save(auth.token, auth.record);
|
||||
return this.currentUser;
|
||||
@@ -362,7 +319,7 @@ export class WorkshopBackend {
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
items: parseListings(result.items, (record, user) => this.listingAuthorAvatarUrl(record, user)),
|
||||
items: parseListings(result.items),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isViewCompatibilityError(error)) throw error;
|
||||
@@ -389,7 +346,7 @@ export class WorkshopBackend {
|
||||
sort: listingSort(options.sort),
|
||||
signal: requestSignal(options.signal),
|
||||
});
|
||||
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
|
||||
return parseListings(rows);
|
||||
} catch (error) {
|
||||
if (!isViewCompatibilityError(error)) throw error;
|
||||
return filterListings(await this.loadListingsFallback(options.signal), options);
|
||||
@@ -409,7 +366,7 @@ export class WorkshopBackend {
|
||||
return this.pocketBase.collection("submissions").getFullList({ ...options, expand: "author" });
|
||||
throw error;
|
||||
});
|
||||
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
|
||||
return parseListings(rows);
|
||||
}
|
||||
|
||||
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
|
||||
@@ -425,10 +382,7 @@ export class WorkshopBackend {
|
||||
throw error;
|
||||
});
|
||||
return workshopSubmissionDetailSchema.parse(
|
||||
enrichAuthor(
|
||||
{ ...submission, ...listing, expand: submission.expand, content: submission.content },
|
||||
(record, user) => this.listingAuthorAvatarUrl(record, user),
|
||||
),
|
||||
enrichAuthor({ ...submission, ...listing, expand: submission.expand, content: submission.content }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Submission not found");
|
||||
@@ -548,7 +502,7 @@ export class WorkshopBackend {
|
||||
return items.map((item) =>
|
||||
workshopReportSchema.parse({
|
||||
...item,
|
||||
reporterName: item.expand?.reporter?.displayName,
|
||||
reporterGithubUsername: item.expand?.reporter?.githubUsername,
|
||||
submissionTitle: item.expand?.submission?.title,
|
||||
}),
|
||||
);
|
||||
@@ -585,13 +539,7 @@ export class WorkshopBackend {
|
||||
return rows.map((row) =>
|
||||
workshopCommentSchema.parse({
|
||||
...row,
|
||||
authorName: row.expand?.author?.displayName,
|
||||
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
|
||||
authorGithubProfileUrl:
|
||||
row.expand?.author?.githubProfileUrl ||
|
||||
(row.expand?.author?.githubUsername
|
||||
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
|
||||
: undefined),
|
||||
authorGithubUsername: row.expand?.author?.githubUsername,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -606,13 +554,7 @@ export class WorkshopBackend {
|
||||
.create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" });
|
||||
return workshopCommentSchema.parse({
|
||||
...row,
|
||||
authorName: row.expand?.author?.displayName,
|
||||
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
|
||||
authorGithubProfileUrl:
|
||||
row.expand?.author?.githubProfileUrl ||
|
||||
(row.expand?.author?.githubUsername
|
||||
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
|
||||
: undefined),
|
||||
authorGithubUsername: row.expand?.author?.githubUsername,
|
||||
});
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to post comment");
|
||||
@@ -626,13 +568,7 @@ export class WorkshopBackend {
|
||||
.update(id, { content: content.trim() }, { expand: "author" });
|
||||
return workshopCommentSchema.parse({
|
||||
...row,
|
||||
authorName: row.expand?.author?.displayName,
|
||||
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
|
||||
authorGithubProfileUrl:
|
||||
row.expand?.author?.githubProfileUrl ||
|
||||
(row.expand?.author?.githubUsername
|
||||
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
|
||||
: undefined),
|
||||
authorGithubUsername: row.expand?.author?.githubUsername,
|
||||
});
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to update comment");
|
||||
|
||||
@@ -70,9 +70,7 @@ describe("WorkshopBackend", () => {
|
||||
token: "token",
|
||||
record: {
|
||||
id: "user-id",
|
||||
displayName: "octocat",
|
||||
githubUsername: "octocat",
|
||||
githubProfileUrl: "https://github.com/octocat",
|
||||
},
|
||||
meta: { username: "octocat" },
|
||||
});
|
||||
@@ -80,14 +78,11 @@ describe("WorkshopBackend", () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const user = await client.signInWithGitHub();
|
||||
|
||||
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
|
||||
provider: "github",
|
||||
createData: { displayName: "GitHub user" },
|
||||
});
|
||||
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({ provider: "github" });
|
||||
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({ displayName: "octocat", githubUsername: "octocat" });
|
||||
expect(user).toMatchObject({ githubUsername: "octocat" });
|
||||
});
|
||||
|
||||
test("exposes the typed submission collection service", () => {
|
||||
@@ -219,7 +214,8 @@ describe("WorkshopBackend", () => {
|
||||
1,
|
||||
12,
|
||||
expect.objectContaining({
|
||||
filter: "(title ~ 'active streams' || description ~ 'active streams' || authorName ~ 'active streams')",
|
||||
filter:
|
||||
"(title ~ 'active streams' || description ~ 'active streams' || authorGithubUsername ~ 'active streams')",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, test } from "vitest";
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
githubAvatarUrl,
|
||||
githubProfileUrl,
|
||||
normalizeHttpUrl,
|
||||
resolveHomarrUrlConfig,
|
||||
validateWorkshopWidget,
|
||||
@@ -11,6 +13,11 @@ import {
|
||||
} from "./schema";
|
||||
|
||||
describe("Workshop URL configuration", () => {
|
||||
test("derives GitHub identity URLs from the stored username", () => {
|
||||
expect(githubProfileUrl("octocat")).toBe("https://github.com/octocat");
|
||||
expect(githubAvatarUrl("octocat")).toBe("https://github.com/octocat.png");
|
||||
});
|
||||
|
||||
test("normalizes the URL contract and derives Workshop defaults", () => {
|
||||
expect(
|
||||
resolveHomarrUrlConfig({
|
||||
@@ -98,7 +105,7 @@ describe("Workshop widget validation", () => {
|
||||
|
||||
expect(result.type).toBe("customWidget");
|
||||
expect(result.screenshots).toEqual([]);
|
||||
expect(result.authorName).toBe("Community member");
|
||||
expect(result.authorGithubUsername).toBe("");
|
||||
expect(result.upvotes).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
|
||||
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
|
||||
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
export const githubProfileUrl = (username: string) =>
|
||||
username ? `https://github.com/${encodeURIComponent(username)}` : "";
|
||||
export const githubAvatarUrl = (username: string) =>
|
||||
username ? `https://github.com/${encodeURIComponent(username)}.png` : "";
|
||||
|
||||
export interface HomarrUrlConfig {
|
||||
homarrWebsiteUrl: string;
|
||||
workshopApiUrl: string;
|
||||
@@ -85,10 +90,7 @@ const workshopFileListSchema = z
|
||||
|
||||
export const workshopUserSchema = z.object({
|
||||
id: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().default(""),
|
||||
githubUsername: z.string().default(""),
|
||||
githubProfileUrl: z.string().default(""),
|
||||
isAdmin: z.boolean().default(false),
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
@@ -111,10 +113,7 @@ const workshopSubmissionBaseObjectSchema = z.object({
|
||||
widgetSchema: z.string(),
|
||||
screenshots: workshopFileListSchema,
|
||||
author: z.string(),
|
||||
authorName: z.string().default("Community member"),
|
||||
authorAvatarUrl: z.string().default(""),
|
||||
authorGithubUsername: z.string().default(""),
|
||||
authorGithubProfileUrl: z.string().default(""),
|
||||
score: z.number().int().default(0),
|
||||
upvotes: z.number().int().default(0),
|
||||
downvotes: z.number().int().default(0),
|
||||
@@ -174,7 +173,7 @@ export const workshopReportSchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
reporter: z.string(),
|
||||
reporterName: z.string().default("Community member"),
|
||||
reporterGithubUsername: z.string().default(""),
|
||||
submissionTitle: z.string().default("Deleted submission"),
|
||||
category: workshopReportCategorySchema,
|
||||
explanation: z.string(),
|
||||
@@ -191,9 +190,7 @@ export const workshopCommentSchema = z.object({
|
||||
content: z.string().min(1).max(2_000),
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
authorName: z.string().default("Community member"),
|
||||
authorAvatarUrl: z.string().default(""),
|
||||
authorGithubProfileUrl: z.string().default(""),
|
||||
authorGithubUsername: z.string().default(""),
|
||||
});
|
||||
export type WorkshopComment = z.infer<typeof workshopCommentSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user