Merge widget Workshop identity fix into mobile

This commit is contained in:
Thomas Camlong committed 2026-08-10 15:15:36 +02:00
commit efd3dae0db
19 files changed
+114 -362

No files matched your search

+5 -2
View File
@@ -95,8 +95,11 @@ 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 uses the OAuth provider username as the public display name instead of
the profile's full name. Configure SMTP for comment, report, and removal notices.
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
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.
The public URL contract is:
@@ -3,6 +3,7 @@ import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTras
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { errorMessage } from "@site/src/lib/utils";
@@ -158,13 +159,12 @@ 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 authorName = ownComment ? (currentUser?.displayName ?? comment.authorName) : comment.authorName;
const avatarUrl = ownComment
? (currentUser?.avatarUrl ?? comment.authorAvatarUrl)
: comment.authorAvatarUrl;
const profileUrl = ownComment
? (currentUser?.githubProfileUrl ?? comment.authorGithubProfileUrl)
: comment.authorGithubProfileUrl;
const authorGithubUsername = ownComment
? (currentUser?.githubUsername ?? comment.authorGithubUsername)
: comment.authorGithubUsername;
const authorName = authorGithubUsername || "Community member";
const avatarUrl = githubAvatarUrl(authorGithubUsername);
const profileUrl = githubProfileUrl(authorGithubUsername);
return (
<article
@@ -292,9 +292,9 @@ 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?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
{currentUser?.githubUsername && <AvatarImage src={githubAvatarUrl(currentUser.githubUsername)} alt="" />}
<AvatarFallback className="bg-background">
{avatarFallback(currentUser?.displayName ?? "Guest")}
{avatarFallback(currentUser?.githubUsername ?? "Guest")}
</AvatarFallback>
</Avatar>
<Textarea
@@ -33,6 +33,8 @@ import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
githubProfileUrl,
MAX_WORKSHOP_SCREENSHOTS,
MAX_WORKSHOP_SCREENSHOT_BYTES,
WORKSHOP_SCREENSHOT_MIME_TYPES,
@@ -524,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 authorName = signedInAuthor?.displayName || submission.authorName;
const authorAvatarUrl = signedInAuthor?.avatarUrl || submission.authorAvatarUrl;
const authorGithubUsername = signedInAuthor?.githubUsername || submission.authorGithubUsername;
const authorGithubProfileUrl = signedInAuthor?.githubProfileUrl || submission.authorGithubProfileUrl;
const authorName = authorGithubUsername || "Community member";
const authorAvatarUrl = githubAvatarUrl(authorGithubUsername);
const authorGithubProfileUrl = githubProfileUrl(authorGithubUsername);
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
@@ -593,10 +595,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{authorName}
{authorGithubProfileUrl && <IconBrandGithub size={14} />}
</a>
<p className="text-xs text-muted-foreground">
{authorGithubUsername ? `@${authorGithubUsername} · ` : ""}
Published {formatRelativeTime(submission.created)}
</p>
<p className="text-xs text-muted-foreground">Published {formatRelativeTime(submission.created)}</p>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -9,10 +10,6 @@ import { cn } from "@/lib/utils";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
const githubProfileUrl = (user: WorkshopUser) =>
user.githubProfileUrl ||
(user.githubUsername ? `https://github.com/${encodeURIComponent(user.githubUsername)}` : undefined);
interface WorkshopAccountMenuProps {
user: WorkshopUser;
onSignOut: () => void;
@@ -21,8 +18,9 @@ interface WorkshopAccountMenuProps {
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
const profileUrl = githubProfileUrl(user.githubUsername);
const avatarUrl = githubAvatarUrl(user.githubUsername);
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : "Account";
useEffect(() => {
if (!open) return;
@@ -54,8 +52,8 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
onClick={() => setOpen((value) => !value)}
>
<Avatar className="size-6">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.githubUsername)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
@@ -73,12 +71,11 @@ export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProp
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.githubUsername)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.displayName}</p>
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
<p className="truncate text-sm font-medium">{accountLabel}</p>
</div>
</div>
@@ -10,6 +10,7 @@ import {
} from "@tabler/icons-react";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -194,18 +195,20 @@ 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.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
{submission.authorGithubUsername && (
<AvatarImage src={githubAvatarUrl(submission.authorGithubUsername)} alt="" />
)}
<AvatarFallback>{avatarFallback(submission.authorGithubUsername)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
href={githubProfileUrl(submission.authorGithubUsername) || undefined}
target={submission.authorGithubUsername ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorName}
{submission.authorGithubUsername || "Community member"}
</a>
</div>
</div>
@@ -237,7 +240,8 @@ 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.reporterName} · {new Date(report.created).toLocaleString()}
Reported by {report.reporterGithubUsername || "Community member"} ·{" "}
{new Date(report.created).toLocaleString()}
</p>
</div>
<Button
@@ -18,6 +18,7 @@ import {
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -98,7 +99,8 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
.filter((item) => includeOutdated || !item.outdated)
.filter(
(item) =>
!q || [item.title, item.description, item.authorName].some((value) => value?.toLowerCase().includes(q)),
!q ||
[item.title, item.description, item.authorGithubUsername].some((value) => value?.toLowerCase().includes(q)),
)
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
@@ -356,16 +358,18 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</div>
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
href={githubProfileUrl(submission.authorGithubUsername) || undefined}
target={submission.authorGithubUsername ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
>
<Avatar className="size-4">
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
{submission.authorGithubUsername && (
<AvatarImage src={githubAvatarUrl(submission.authorGithubUsername)} alt="" />
)}
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorGithubUsername)}</AvatarFallback>
</Avatar>
<span className="truncate">{submission.authorName}</span>
<span className="truncate">{submission.authorGithubUsername || "Community member"}</span>
</a>
<span className="shrink-0 whitespace-nowrap">
· v{submission.revision} · {formatRelativeTime(submission.created)}