feat(custom-widgets): complete JSX v2
This commit is contained in:
263 files changed
+65503
-17816
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkshopAdminAction, WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
|
||||
import { WorkshopClient } from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
export function WorkshopAdmin() {
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient(typeof window === "undefined" ? undefined : window.location.origin),
|
||||
[],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopAdminAction[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [nextReports, nextSubmissions, nextActions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listAll({ sort: "newest" }),
|
||||
client.listAdminActions(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setSubmissions(nextSubmissions);
|
||||
setActions(nextActions);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then((next) => {
|
||||
setUser(next);
|
||||
if (next?.isAdmin) void load();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [client, load]);
|
||||
|
||||
const grouped = submissions.map((submission) => ({
|
||||
submission,
|
||||
reports: reports.filter((report) => report.submission === submission.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className={styles.shell}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Workshop administration</h1>
|
||||
<p>
|
||||
Review reports, see each reporter and vote totals, dismiss reports, or delete any submission. This page does
|
||||
not manage user accounts.
|
||||
</p>
|
||||
{!user && (
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() =>
|
||||
void client.signInWithGitHub().then((next) => {
|
||||
setUser(next);
|
||||
if (next?.isAdmin) void load();
|
||||
})
|
||||
}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{user?.isAdmin && (
|
||||
<div className={styles.stack} style={{ marginTop: "2rem" }}>
|
||||
<h2>Submissions and open reports</h2>
|
||||
{grouped.map(({ submission, reports: submissionReports }) => (
|
||||
<section key={submission.id} className={styles.adminGroup}>
|
||||
<div className={styles.meta}>
|
||||
<strong>{submission.title}</strong>
|
||||
<span>
|
||||
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score} ·{" "}
|
||||
{submissionReports.length} reports
|
||||
</span>
|
||||
</div>
|
||||
<p>{submission.description}</p>
|
||||
{submissionReports.map((report) => (
|
||||
<div key={report.id} className={styles.report}>
|
||||
<strong>{report.category}</strong> by {report.reporterName}
|
||||
<p>{report.explanation}</p>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why is this report being dismissed?")?.trim();
|
||||
if (reason) void client.dismissReport(report.id, reason).then(load);
|
||||
}}
|
||||
>
|
||||
Dismiss report
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.actions}>
|
||||
<a className={styles.secondary} href={`/workshop?id=${submission.id}`}>
|
||||
Inspect
|
||||
</a>
|
||||
<button
|
||||
className={styles.danger}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${submission.title}? Installed local copies will remain.`))
|
||||
void client.delete(submission.id, "Deleted after administrator review").then(load);
|
||||
}}
|
||||
>
|
||||
Delete submission
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<h2>Administrator audit trail</h2>
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className={`${styles.adminGroup} ${styles.audit}`}>
|
||||
{new Date(action.created).toLocaleString()} · {action.actorName} · {action.action} · submission{" "}
|
||||
{action.submissionId || "—"} · report {action.reportId || "—"}
|
||||
<br />
|
||||
{action.reason}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
WorkshopReport,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionInput,
|
||||
WorkshopSubmissionSummary,
|
||||
WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
import { validateWorkshopWidget, WorkshopClient } from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const emptyDraft: WorkshopSubmissionInput = {
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
changelog: "Initial publication",
|
||||
};
|
||||
|
||||
export function WorkshopPage() {
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient(typeof window === "undefined" ? undefined : window.location.origin),
|
||||
[],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [draft, setDraft] = useState<WorkshopSubmissionInput>(emptyDraft);
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await client.list({ search, sort, perPage: 60 })).items);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
|
||||
}
|
||||
}, [client, search, sort]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setSelected(await client.get(id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load submission");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
const requestedSubmission = new URLSearchParams(window.location.search).get("id");
|
||||
if (requestedSubmission) void openDetail(requestedSubmission);
|
||||
return unsubscribe;
|
||||
}, [client, openDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (selected && selected.author === user?.id) await client.update(selected.id, draft, screenshots);
|
||||
else await client.create(draft, screenshots);
|
||||
setEditorOpen(false);
|
||||
setDraft(emptyDraft);
|
||||
setScreenshots([]);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to publish");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const edit = () => {
|
||||
if (!selected) return;
|
||||
setDraft({
|
||||
title: selected.title,
|
||||
description: selected.description,
|
||||
content: selected.content,
|
||||
changelog: selected.changelog,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.shell}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Workshop</h1>
|
||||
<p>
|
||||
Discover and publish Custom JSX v2 widgets. Every submission is public immediately; the community can vote and
|
||||
report unsafe content.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
{user ? (
|
||||
<>
|
||||
<span>
|
||||
Signed in as <strong>{user.displayName}</strong>
|
||||
</span>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
client.signOut();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setDraft(emptyDraft);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Publish widget
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() =>
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: Error) => setError(cause.message))
|
||||
}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.notice}>
|
||||
Inspect API origins, methods, permissions, and JSX before importing. Workshop never stores widget credentials.
|
||||
</div>
|
||||
<div className={styles.toolbar}>
|
||||
<input
|
||||
className={styles.input}
|
||||
aria-label="Search Workshop"
|
||||
placeholder="Search widgets"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
/>
|
||||
<select
|
||||
className={styles.select}
|
||||
style={{ width: 180 }}
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.currentTarget.value as "top" | "newest")}
|
||||
>
|
||||
<option value="top">Top rated</option>
|
||||
<option value="newest">Newest</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className={styles.card}>
|
||||
{item.screenshots[0] ? (
|
||||
<img
|
||||
className={styles.previewImage}
|
||||
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.preview}>JSX</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.meta}>
|
||||
<span>Revision {item.revision}</span>
|
||||
<span>
|
||||
▲ {item.upvotes} · ▼ {item.downvotes}
|
||||
</span>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description || "No description"}</p>
|
||||
<small>by {item.authorName}</small>
|
||||
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
|
||||
Inspect widget
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => event.target === event.currentTarget && setSelected(null)}
|
||||
>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label={selected.title}>
|
||||
<h2>{selected.title}</h2>
|
||||
<p>{selected.description}</p>
|
||||
<div className={styles.meta}>
|
||||
<span>
|
||||
by {selected.authorName} · revision {selected.revision}
|
||||
</span>
|
||||
<span>SHA-256 {selected.contentHash.slice(0, 12)}…</span>
|
||||
</div>
|
||||
{selected.screenshots.length > 0 && (
|
||||
<div className={styles.screenshots}>
|
||||
{selected.screenshots.map((file, index) => (
|
||||
<img
|
||||
key={file}
|
||||
src={client.fileUrl(selected.id, file, "960x640")}
|
||||
alt={`${selected.title} screenshot ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<WidgetCapabilitySummary content={selected.content} />
|
||||
<pre className={styles.code}>{selected.content}</pre>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
disabled={!user}
|
||||
onClick={() =>
|
||||
void client
|
||||
.vote(selected.id, 1)
|
||||
.then(() => openDetail(selected.id))
|
||||
.then(load)
|
||||
}
|
||||
>
|
||||
▲ {selected.upvotes}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
disabled={!user}
|
||||
onClick={() =>
|
||||
void client
|
||||
.vote(selected.id, -1)
|
||||
.then(() => openDetail(selected.id))
|
||||
.then(load)
|
||||
}
|
||||
>
|
||||
▼ {selected.downvotes}
|
||||
</button>
|
||||
<button className={styles.danger} disabled={!user} onClick={() => setReportOpen(true)}>
|
||||
Report
|
||||
</button>
|
||||
{selected.author === user?.id && (
|
||||
<>
|
||||
<button className={styles.secondary} onClick={edit}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className={styles.danger}
|
||||
onClick={() =>
|
||||
void client.delete(selected.id).then(() => {
|
||||
setSelected(null);
|
||||
return load();
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(new Blob([selected.content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${selected.title}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setSelected(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editorOpen && (
|
||||
<div className={styles.overlay}>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label="Publish widget">
|
||||
<h2>{selected?.author === user?.id ? "Update submission" : "Publish widget"}</h2>
|
||||
<label className={styles.field}>
|
||||
Title
|
||||
<input
|
||||
className={styles.input}
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Description
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft({ ...draft, description: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Canonical widget.json
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
style={{ minHeight: 260 }}
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Changelog
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={draft.changelog}
|
||||
onChange={(event) => setDraft({ ...draft, changelog: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Screenshots
|
||||
<input
|
||||
className={styles.input}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setScreenshots(Array.from(event.currentTarget.files ?? []))}
|
||||
/>
|
||||
<span className={styles.hint}>Up to five images, 5 MiB each.</span>
|
||||
</label>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.button} disabled={busy || !user} onClick={() => void save()}>
|
||||
{busy ? "Publishing…" : "Publish immediately"}
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setEditorOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportOpen && selected && (
|
||||
<div className={styles.overlay}>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label="Report submission">
|
||||
<h2>Report {selected.title}</h2>
|
||||
<label className={styles.field}>
|
||||
Category
|
||||
<select
|
||||
className={styles.select}
|
||||
value={reportCategory}
|
||||
onChange={(event) => setReportCategory(event.currentTarget.value as WorkshopReport["category"])}
|
||||
>
|
||||
{["malicious", "spam", "copyright", "inappropriate", "other"].map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Explanation
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={reportText}
|
||||
onChange={(event) => setReportText(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.danger}
|
||||
disabled={reportText.trim().length < 3}
|
||||
onClick={() =>
|
||||
void client.report(selected.id, reportCategory, reportText).then(() => {
|
||||
setReportOpen(false);
|
||||
setReportText("");
|
||||
})
|
||||
}
|
||||
>
|
||||
Send report
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setReportOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetCapabilitySummary({ content }: { content: string }) {
|
||||
const result = validateWorkshopWidget(content);
|
||||
if (!result.success) return <div className={styles.error}>{result.error}</div>;
|
||||
return (
|
||||
<div className={styles.capabilities}>
|
||||
<section>
|
||||
<strong>API sources</strong>
|
||||
{result.data.sources.map((source) => (
|
||||
<p key={source.id}>
|
||||
<code>{new URL(source.baseUrl).origin}</code> · {source.networkScope} · {source.auth.type}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<strong>Requests and permissions</strong>
|
||||
{result.data.requests.length === 0 ? (
|
||||
<p>No network requests</p>
|
||||
) : (
|
||||
result.data.requests.map((request) => (
|
||||
<p key={request.id}>
|
||||
<code>{request.id}</code> · {request.kind} · {request.method} · {request.minimumBoardPermission}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
.shell {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 12%, transparent), transparent 60%);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.25rem, 6vw, 4.5rem);
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.toolbar,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar {
|
||||
margin: 2rem 0 1.25rem;
|
||||
}
|
||||
.toolbar input {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.button,
|
||||
.secondary,
|
||||
.danger {
|
||||
border: 0;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.7rem 1rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button {
|
||||
color: white;
|
||||
background: var(--ifm-color-primary);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
}
|
||||
.danger {
|
||||
color: white;
|
||||
background: var(--ifm-color-danger-dark);
|
||||
}
|
||||
.button:disabled,
|
||||
.secondary:disabled,
|
||||
.danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
font: inherit;
|
||||
}
|
||||
.textarea {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hint {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 1rem;
|
||||
background: var(--ifm-card-background-color);
|
||||
}
|
||||
.cardBody {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.cardBody h2,
|
||||
.cardBody p {
|
||||
margin: 0;
|
||||
}
|
||||
.cardBody p {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 160px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
font: 800 1.5rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
.previewImage {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(0 0 0 / 0.62);
|
||||
}
|
||||
.dialog {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: min(820px, 100%);
|
||||
max-height: 90dvh;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: var(--ifm-background-color);
|
||||
color: inherit;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.dialog h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.code {
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
white-space: pre;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.notice,
|
||||
.error,
|
||||
.success {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.notice {
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 20%, transparent);
|
||||
}
|
||||
.error {
|
||||
background: color-mix(in srgb, var(--ifm-color-danger) 20%, transparent);
|
||||
}
|
||||
.success {
|
||||
background: color-mix(in srgb, var(--ifm-color-success) 20%, transparent);
|
||||
}
|
||||
.screenshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.screenshots img {
|
||||
width: 100%;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.capabilities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.capabilities section {
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.capabilities p {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.adminGroup {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
.report {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--ifm-color-warning);
|
||||
}
|
||||
.audit {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
width: min(100% - 1rem, 1180px);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.dialog {
|
||||
padding: 1rem;
|
||||
}
|
||||
.actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
|
||||
|
||||
export default function WorkshopAdminRoute() {
|
||||
return (
|
||||
<Layout title="Workshop administration" noFooter>
|
||||
<WorkshopAdmin />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopPage } from "@site/src/components/workshop/WorkshopPage";
|
||||
|
||||
export default function WorkshopRoute() {
|
||||
return (
|
||||
<Layout title="Workshop" description="Discover and publish Homarr Custom JSX widgets">
|
||||
<WorkshopPage />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user