Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant
This commit is contained in:
9 files changed
+197
-1
No files matched your search
@@ -93,6 +93,8 @@ Custom Widget imports detect required bearer, basic, header, or query credential
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
|
||||
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Head from "@docusaurus/Head";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
@@ -69,6 +70,7 @@ import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
|
||||
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeSocialLabels: Record<SubmissionType, string> = { customCss: "Custom CSS", customWidget: "Custom widget" };
|
||||
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
|
||||
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
|
||||
const copyState = [
|
||||
@@ -536,9 +538,31 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
|
||||
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
|
||||
const displayedContent = formatWorkshopContent(submission.type, submission.content);
|
||||
const socialTitle = `${submission.title} · Homarr Workshop`;
|
||||
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
|
||||
const socialUrl = `${window.location.origin}${window.location.pathname}`;
|
||||
const socialImage = screenshotUrls[0] ?? `${window.location.origin}/img/logo.png`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head>
|
||||
<title>{socialTitle}</title>
|
||||
<meta name="description" content={socialDescription} />
|
||||
<link rel="canonical" href={socialUrl} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:site_name" content="Homarr Workshop" />
|
||||
<meta property="og:title" content={socialTitle} />
|
||||
<meta property="og:description" content={socialDescription} />
|
||||
<meta property="og:url" content={socialUrl} />
|
||||
<meta property="og:image" content={socialImage} />
|
||||
<meta property="og:image:alt" content={`${submission.title} preview`} />
|
||||
<meta property="article:section" content={typeSocialLabels[submission.type]} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={socialTitle} />
|
||||
<meta name="twitter:description" content={socialDescription} />
|
||||
<meta name="twitter:image" content={socialImage} />
|
||||
<meta name="twitter:image:alt" content={`${submission.title} preview`} />
|
||||
</Head>
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<Link
|
||||
to="/workshop"
|
||||
|
||||
@@ -205,7 +205,7 @@ export const WidgetContextMenu = ({
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||
|
||||
|
||||
@@ -10,6 +10,37 @@ const escapeHtml = (value) =>
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const renderWorkshopSocialHtml = (html, metadata) => {
|
||||
const title = escapeHtml(metadata.title);
|
||||
const description = escapeHtml(metadata.description);
|
||||
const url = escapeHtml(metadata.url);
|
||||
const image = escapeHtml(metadata.image);
|
||||
const imageAlt = escapeHtml(`${metadata.submissionTitle} preview`);
|
||||
const section = escapeHtml(metadata.section);
|
||||
const tags = [
|
||||
`<title>${title}</title>`,
|
||||
`<meta name="description" content="${description}">`,
|
||||
`<link rel="canonical" href="${url}">`,
|
||||
`<meta property="og:type" content="article">`,
|
||||
`<meta property="og:site_name" content="Homarr Workshop">`,
|
||||
`<meta property="og:title" content="${title}">`,
|
||||
`<meta property="og:description" content="${description}">`,
|
||||
`<meta property="og:url" content="${url}">`,
|
||||
`<meta property="article:section" content="${section}">`,
|
||||
`<meta name="twitter:card" content="summary_large_image">`,
|
||||
`<meta name="twitter:title" content="${title}">`,
|
||||
`<meta name="twitter:description" content="${description}">`,
|
||||
`<meta property="og:image" content="${image}">`,
|
||||
`<meta property="og:image:alt" content="${imageAlt}">`,
|
||||
`<meta name="twitter:image" content="${image}">`,
|
||||
`<meta name="twitter:image:alt" content="${imageAlt}">`,
|
||||
].join("\n");
|
||||
const replaceableTags =
|
||||
/<title\b[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*(?:name|property)=["'](?:description|og:(?:title|description|url|image|image:alt|type|site_name)|twitter:(?:card|title|description|image|image:alt)|article:section)["'][^>]*>|<link\b[^>]*rel=["']canonical["'][^>]*>/gi;
|
||||
|
||||
return html.replace(replaceableTags, "").replace("<head>", `<head>\n${tags}`);
|
||||
};
|
||||
|
||||
const emailTemplate = (filename) => {
|
||||
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
|
||||
return $os.readFile($filepath.join(hooksPath, filename));
|
||||
@@ -32,5 +63,6 @@ module.exports = {
|
||||
emailTemplate,
|
||||
escapeHtml,
|
||||
rejectRequest,
|
||||
renderWorkshopSocialHtml,
|
||||
sendEmail,
|
||||
};
|
||||
@@ -17,6 +17,46 @@ onBootstrap((event) => {
|
||||
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
|
||||
});
|
||||
|
||||
const workshopDetailPage = (event) => {
|
||||
const id = event.request.pathValue("id");
|
||||
if (id === "admin") return event.fileFS($os.dirFS("/pb_public"), "workshop/admin/index.html");
|
||||
|
||||
const indexHtml = toString($os.readFile("/pb_public/index.html"));
|
||||
try {
|
||||
const { renderWorkshopSocialHtml } = require(`${__hooks}/workshop-utils.js`);
|
||||
const submission = event.app.findRecordById("submissions", id);
|
||||
const type = submission.getString("type");
|
||||
const section = type === "customCss" ? "Custom CSS" : "Custom widget";
|
||||
const submissionTitle = submission.getString("title");
|
||||
const title = `${submissionTitle} · Homarr Workshop`;
|
||||
const description = `${section} for Homarr. ${submission.getString("description")}`.trim();
|
||||
const workshopUrl = $os.getenv("WORKSHOP_WEB_URL").replace(/\/$/, "");
|
||||
const apiUrl = $os.getenv("WORKSHOP_API_URL").replace(/\/$/, "");
|
||||
const websiteUrl = $os.getenv("HOMARR_WEBSITE_URL").replace(/\/$/, "");
|
||||
const screenshot = submission.getStringSlice("screenshots")[0];
|
||||
const image = screenshot
|
||||
? `${apiUrl}/api/files/submissions/${submission.id}/${encodeURIComponent(screenshot)}`
|
||||
: `${websiteUrl}/img/logo.png`;
|
||||
|
||||
return event.html(
|
||||
200,
|
||||
renderWorkshopSocialHtml(indexHtml, {
|
||||
title,
|
||||
description,
|
||||
url: `${workshopUrl}/${submission.id}`,
|
||||
image,
|
||||
section,
|
||||
submissionTitle,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return event.html(200, indexHtml);
|
||||
}
|
||||
};
|
||||
|
||||
routerAdd("GET", "/workshop/{id}", workshopDetailPage);
|
||||
routerAdd("GET", "/workshop/{id}/{$}", workshopDetailPage);
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
event.record.set("revision", 1);
|
||||
event.record.set("expectedRevision", 0);
|
||||
|
||||
@@ -38,6 +38,13 @@ WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
|
||||
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
|
||||
node apps/workshop/tests/runtime-config.integration.mjs
|
||||
|
||||
docker exec "$WORKSHOP_IMAGE_TEST_NAME" pocketbase superuser create \
|
||||
workshop-image@example.invalid 'WorkshopImageTest123!' --dir=/pb_data
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
|
||||
EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
|
||||
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
|
||||
node apps/workshop/tests/workshop-social-metadata.integration.mjs
|
||||
|
||||
curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null
|
||||
curl --fail --silent \
|
||||
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const read = (path) => readFile(resolve(path), "utf8");
|
||||
|
||||
@@ -39,6 +40,34 @@ for (const artifact of [
|
||||
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
|
||||
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
|
||||
}
|
||||
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
|
||||
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
|
||||
}
|
||||
|
||||
const hookUtilsModule = { exports: {} };
|
||||
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
|
||||
const socialHtml = hookUtilsModule.exports.renderWorkshopSocialHtml(
|
||||
'<html><head><title data-rh="true">Homarr documentation</title><meta data-rh="true" property="og:title" content="Homarr documentation"><meta data-rh="true" name="description" content="Generic"><link data-rh="true" rel="canonical" href="https://homarr.dev/"></head></html>',
|
||||
{
|
||||
title: "Ocean <Glow> · Homarr Workshop",
|
||||
description: "Custom CSS for Homarr. Calm & readable.",
|
||||
url: "https://preview.example/workshop/abc",
|
||||
image: "https://preview.example/api/files/submissions/abc/preview.png",
|
||||
section: "Custom CSS",
|
||||
submissionTitle: "Ocean <Glow>",
|
||||
},
|
||||
);
|
||||
for (const expected of [
|
||||
"<title>Ocean <Glow> · Homarr Workshop</title>",
|
||||
'property="og:description" content="Custom CSS for Homarr. Calm & readable."',
|
||||
'rel="canonical" href="https://preview.example/workshop/abc"',
|
||||
'property="og:image" content="https://preview.example/api/files/submissions/abc/preview.png"',
|
||||
'name="twitter:card" content="summary_large_image"',
|
||||
'property="article:section" content="Custom CSS"',
|
||||
]) {
|
||||
if (!socialHtml.includes(expected)) throw new Error(`Workshop social metadata is missing: ${expected}`);
|
||||
}
|
||||
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
|
||||
|
||||
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
|
||||
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18091";
|
||||
const publicWorkshopUrl = process.env.EXPECTED_WORKSHOP_WEB_URL ?? "https://v2.preview.homarr.dev/workshop";
|
||||
const publicApiUrl = process.env.EXPECTED_WORKSHOP_API_URL ?? "https://v2.preview.homarr.dev";
|
||||
|
||||
const request = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, init);
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
};
|
||||
|
||||
const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ identity: "workshop-image@example.invalid", password: "WorkshopImageTest123!" }),
|
||||
});
|
||||
const headers = { authorization: `Bearer ${root.token}` };
|
||||
const author = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "social-author@example.invalid",
|
||||
password: "WorkshopSocialTest123!",
|
||||
passwordConfirm: "WorkshopSocialTest123!",
|
||||
name: "social-author",
|
||||
}),
|
||||
});
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const data = new FormData();
|
||||
data.set("type", "customCss");
|
||||
data.set("title", "Ocean <Glow>");
|
||||
data.set("description", "Calm & readable.");
|
||||
data.set("widgetSchema", "homarr-custom-css-v1");
|
||||
data.set("content", "body { color: white; }");
|
||||
data.set("author", author.id);
|
||||
data.set("screenshots", new File([png], "preview.png", { type: "image/png" }));
|
||||
const submission = await request("/api/collections/submissions/records", { method: "POST", headers, body: data });
|
||||
const screenshot = encodeURIComponent(submission.screenshots[0]);
|
||||
const html = await fetch(`${baseUrl}/workshop/${submission.id}`).then((response) => response.text());
|
||||
|
||||
for (const expected of [
|
||||
"<title>Ocean <Glow> · Homarr Workshop</title>",
|
||||
'property="og:description" content="Custom CSS for Homarr. Calm & readable."',
|
||||
`rel="canonical" href="${publicWorkshopUrl}/${submission.id}"`,
|
||||
`property="og:image" content="${publicApiUrl}/api/files/submissions/${submission.id}/${screenshot}"`,
|
||||
'name="twitter:card" content="summary_large_image"',
|
||||
'property="article:section" content="Custom CSS"',
|
||||
]) {
|
||||
if (!html.includes(expected)) throw new Error(`Rendered Workshop page is missing social metadata: ${expected}`);
|
||||
}
|
||||
|
||||
console.log("Workshop social metadata integration passed");
|
||||
@@ -151,6 +151,13 @@ describe("Board grid", () => {
|
||||
await nestedAppLink.click();
|
||||
await expect(page).toHaveURL(/#board-app-opened$/);
|
||||
await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`));
|
||||
|
||||
await firstItem.click({ button: "right" });
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await nestedItem.click({ button: "right" });
|
||||
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||
|
||||
const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile);
|
||||
const secondItemBox = await expectBoundingBoxAsync(secondItem);
|
||||
expect(secondItemBox.x - (normalZoomTileBox.x + normalZoomTileBox.width)).toBeCloseTo(
|
||||
|
||||
Reference in new issue
Block a user