Add Gitea Actions deploy workflow and server configuration
Some checks failed
Deploy to Server / deploy (push) Failing after 1m49s
Some checks failed
Deploy to Server / deploy (push) Failing after 1m49s
- Add basePath /admin to Next.js config for path-based routing - Add .gitea/workflows/deploy.yml for CI/CD via Gitea Actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -204,9 +204,25 @@ export default function ContentEditPage() {
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
<h1 className="mb-6 text-2xl font-semibold text-gray-900">
|
||||
<h1 className="mb-2 text-2xl font-semibold text-gray-900">
|
||||
{t("title")} — {slug}
|
||||
</h1>
|
||||
{entry && (entry._created != null || entry._updated != null) && (
|
||||
<p className="mb-6 text-sm text-gray-500">
|
||||
{entry._created != null && (
|
||||
<span>
|
||||
{t("created")}: {new Date(String(entry._created)).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
)}
|
||||
{entry._created != null && entry._updated != null && " · "}
|
||||
{entry._updated != null && (
|
||||
<span>
|
||||
{t("lastEdited")}: {new Date(String(entry._updated)).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{entry && !entry._created && !entry._updated && <div className="mb-6" />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
|
||||
@@ -104,6 +104,20 @@ export default function ContentListPage() {
|
||||
};
|
||||
const isSortSlug = sort === "_slug" || !sort;
|
||||
const nextSlugOrder = isSortSlug && order === "asc" ? "desc" : "asc";
|
||||
const isSortCreated = sort === "_created";
|
||||
const nextCreatedOrder = isSortCreated && order === "desc" ? "asc" : "desc";
|
||||
const isSortUpdated = sort === "_updated";
|
||||
const nextUpdatedOrder = isSortUpdated && order === "desc" ? "asc" : "desc";
|
||||
|
||||
function formatListDate(iso: string | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const tBread = useTranslations("Breadcrumbs");
|
||||
|
||||
@@ -245,7 +259,7 @@ export default function ContentListPage() {
|
||||
<TableHead className="w-64 max-w-[16rem]">
|
||||
<Link
|
||||
href={`/content/${collection}?${sortQuery("_slug", nextSlugOrder)}`}
|
||||
className="inline-flex items-center gap-1 font-medium hover:underline"
|
||||
className="inline-flex items-center gap-1 font-medium no-underline hover:text-accent-800"
|
||||
title={t("sortBy", { field: "_slug" })}
|
||||
>
|
||||
_slug
|
||||
@@ -259,6 +273,38 @@ export default function ContentListPage() {
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="w-24">{t("colStatus")}</TableHead>
|
||||
<TableHead className="w-36 whitespace-nowrap">
|
||||
<Link
|
||||
href={`/content/${collection}?${sortQuery("_created", nextCreatedOrder)}`}
|
||||
className="inline-flex items-center gap-1 font-medium no-underline hover:text-accent-800"
|
||||
title={t("sortBy", { field: t("colCreated") })}
|
||||
>
|
||||
{t("colCreated")}
|
||||
{isSortCreated && (
|
||||
<Icon
|
||||
icon={order === "desc" ? "mdi:chevron-down" : "mdi:chevron-up"}
|
||||
className="size-4"
|
||||
aria-label={order === "desc" ? t("sortDesc") : t("sortAsc")}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="w-36 whitespace-nowrap">
|
||||
<Link
|
||||
href={`/content/${collection}?${sortQuery("_updated", nextUpdatedOrder)}`}
|
||||
className="inline-flex items-center gap-1 font-medium no-underline hover:text-accent-800"
|
||||
title={t("sortBy", { field: t("colLastEdited") })}
|
||||
>
|
||||
{t("colLastEdited")}
|
||||
{isSortUpdated && (
|
||||
<Icon
|
||||
icon={order === "desc" ? "mdi:chevron-down" : "mdi:chevron-up"}
|
||||
className="size-4"
|
||||
aria-label={order === "desc" ? t("sortDesc") : t("sortAsc")}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -273,7 +319,7 @@ export default function ContentListPage() {
|
||||
<TableCell className="w-64 max-w-[16rem] font-mono text-sm">
|
||||
<Link
|
||||
href={editHref}
|
||||
className="block truncate text-accent-700 hover:underline hover:text-accent-900"
|
||||
className="block truncate text-accent-700 no-underline hover:text-accent-900"
|
||||
title={slug}
|
||||
>
|
||||
{slug}
|
||||
@@ -290,6 +336,12 @@ export default function ContentListPage() {
|
||||
{isDraft ? t("draft") : t("published")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="w-36 text-sm text-gray-600">
|
||||
{formatListDate(entry._created as string | undefined)}
|
||||
</TableCell>
|
||||
<TableCell className="w-36 text-sm text-gray-600">
|
||||
{formatListDate(entry._updated as string | undefined)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="icon-sm" asChild className="min-h-[44px] w-11 sm:min-h-0">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { fetchCollections, type CollectionMeta } from "@/lib/api";
|
||||
import { DashboardCollectionList } from "@/components/DashboardCollectionList";
|
||||
import { DashboardRecentEdits } from "@/components/DashboardRecentEdits";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const t = await getTranslations("Dashboard");
|
||||
@@ -30,6 +31,7 @@ export default async function DashboardPage() {
|
||||
{t("newContentType")}
|
||||
</Link>
|
||||
</div>
|
||||
<DashboardRecentEdits collections={collections} />
|
||||
<DashboardCollectionList collections={collections} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -645,8 +645,17 @@ export function ContentForm({
|
||||
flush();
|
||||
}
|
||||
|
||||
const onInvalid = (errs: Record<string, unknown>) => {
|
||||
const firstKey = Object.keys(errs).find((k) => k !== "root" && errs[k]);
|
||||
if (firstKey) {
|
||||
const el = document.querySelector(`[name="${firstKey}"], [data-field="${firstKey}"]`);
|
||||
if (el instanceof HTMLElement) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
toast.error(t("validationErrors"));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="max-w-2xl space-y-6">
|
||||
{errors.root && (
|
||||
<div
|
||||
className="rounded bg-red-50 p-3 text-sm text-red-700"
|
||||
@@ -656,20 +665,22 @@ export function ContentForm({
|
||||
</div>
|
||||
)}
|
||||
{showSlugField && (
|
||||
<SlugField
|
||||
collection={collection}
|
||||
currentSlug={slug}
|
||||
slugPrefix={!isEdit ? slugPrefixForCollection(collection) : undefined}
|
||||
locale={locale}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
setError={setError}
|
||||
clearErrors={clearErrors}
|
||||
error={errors._slug}
|
||||
slugValue={watch("_slug") as string | undefined}
|
||||
/>
|
||||
<div data-field="_slug">
|
||||
<SlugField
|
||||
collection={collection}
|
||||
currentSlug={slug}
|
||||
slugPrefix={!isEdit ? slugPrefixForCollection(collection) : undefined}
|
||||
locale={locale}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
setError={setError}
|
||||
clearErrors={clearErrors}
|
||||
error={errors._slug}
|
||||
slugValue={watch("_slug") as string | undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div data-field="_status">
|
||||
<Label className="mb-1 block font-medium">{t("status")}</Label>
|
||||
<Controller
|
||||
name="_status"
|
||||
@@ -729,7 +740,7 @@ export function ContentForm({
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
{item.entries.map(([name, def]) => (
|
||||
<div key={name} className={FIELD_BLOCK_CLASS}>
|
||||
<div key={name} className={FIELD_BLOCK_CLASS} data-field={name}>
|
||||
<Field
|
||||
name={name}
|
||||
def={def}
|
||||
@@ -758,7 +769,7 @@ export function ContentForm({
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<div key={name} className={FIELD_BLOCK_CLASS}>
|
||||
<div key={name} className={FIELD_BLOCK_CLASS} data-field={name}>
|
||||
<Field
|
||||
name={name}
|
||||
def={def as FieldDefinition}
|
||||
|
||||
101
admin-ui/src/components/DashboardRecentEdits.tsx
Normal file
101
admin-ui/src/components/DashboardRecentEdits.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { fetchContentList } from "@/lib/api";
|
||||
import type { CollectionMeta } from "@/lib/api";
|
||||
|
||||
const RECENT_COLLECTION = "post";
|
||||
const RECENT_LIMIT = 3;
|
||||
|
||||
type Props = {
|
||||
collections: CollectionMeta[];
|
||||
};
|
||||
|
||||
function formatListDate(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardRecentEdits({ collections }: Props) {
|
||||
const t = useTranslations("Dashboard");
|
||||
const hasPost = collections.some((c) => c.name === RECENT_COLLECTION);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["content", RECENT_COLLECTION, "_updated", "desc", RECENT_LIMIT],
|
||||
queryFn: () =>
|
||||
fetchContentList(RECENT_COLLECTION, {
|
||||
_sort: "_updated",
|
||||
_order: "desc",
|
||||
_per_page: RECENT_LIMIT,
|
||||
_status: "all",
|
||||
}),
|
||||
enabled: hasPost,
|
||||
});
|
||||
|
||||
if (!hasPost) return null;
|
||||
const items = (data?.items ?? []) as Record<string, unknown>[];
|
||||
|
||||
return (
|
||||
<section className="mb-8 rounded-xl border border-gray-200 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{t("recentSectionTitle")}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/content/${RECENT_COLLECTION}`}
|
||||
className="text-sm font-medium text-accent-600 hover:text-accent-800 hover:underline"
|
||||
>
|
||||
{t("recentSectionLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{isLoading && <p className="text-sm text-gray-500">{t("loading")}</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<p className="text-sm text-gray-500">{t("recentSectionEmpty")}</p>
|
||||
)}
|
||||
{!isLoading && !error && items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{items.map((entry) => {
|
||||
const slug = entry._slug as string | undefined;
|
||||
if (!slug) return null;
|
||||
const updated = entry._updated as string | undefined;
|
||||
return (
|
||||
<li key={slug}>
|
||||
<Link
|
||||
href={`/content/${RECENT_COLLECTION}/${encodeURIComponent(slug)}`}
|
||||
className="flex flex-wrap items-baseline justify-between gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-sm no-underline! hover:bg-gray-100/80 hover:border-gray-200"
|
||||
>
|
||||
<span
|
||||
className="font-mono text-gray-900 truncate max-w-[70%]"
|
||||
title={slug}
|
||||
>
|
||||
{slug}
|
||||
</span>
|
||||
{updated && (
|
||||
<span className="shrink-0 text-xs text-gray-500">
|
||||
{formatListDate(updated)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef } from "react";
|
||||
import { useId, useRef, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getBaseUrl, expandAssetUrlsInText } from "@/lib/api";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -57,6 +59,10 @@ export function MarkdownEditor({
|
||||
const id = useId();
|
||||
const previewId = `${id}-preview`;
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewMarkdown = useMemo(
|
||||
() => expandAssetUrlsInText(value ?? "", getBaseUrl()),
|
||||
[value]
|
||||
);
|
||||
|
||||
const apply = (result: { newText: string; newStart: number; newEnd: number }) => {
|
||||
onChange(result.newText);
|
||||
@@ -162,7 +168,7 @@ export function MarkdownEditor({
|
||||
className="min-h-[120px] rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 [&_ul]:list-inside [&_ul]:list-disc [&_ol]:list-inside [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-gray-200 [&_pre]:p-2 [&_code]:rounded [&_code]:bg-gray-200 [&_code]:px-1 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold [&_h3]:text-sm [&_h3]:font-bold [&_a]:text-accent-700 [&_a]:underline [&_a:hover]:text-accent-800"
|
||||
>
|
||||
{(value ?? "").trim() ? (
|
||||
<ReactMarkdown>{value}</ReactMarkdown>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{previewMarkdown}</ReactMarkdown>
|
||||
) : (
|
||||
<span className="text-gray-400">{t("emptyPreview")}</span>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
export const getBaseUrl = () =>
|
||||
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
|
||||
|
||||
/**
|
||||
* Expand relative /api/assets/ paths in text (e.g. markdown) to full URLs for preview/display.
|
||||
* Idempotent: already-expanded base URL is preserved.
|
||||
*/
|
||||
export function expandAssetUrlsInText(text: string, baseUrl: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
const fullPrefix = `${base}/api/assets/`;
|
||||
if (!text.includes("/api/assets/")) return text;
|
||||
const placeholder = "\x00__ASSET_BASE__\x00";
|
||||
return text
|
||||
.replaceAll(fullPrefix, placeholder)
|
||||
.replaceAll("/api/assets/", fullPrefix)
|
||||
.replaceAll(placeholder, fullPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collapse absolute asset URLs to relative paths so the admin
|
||||
* keeps and displays /api/assets/... and the backend stores relative.
|
||||
@@ -14,10 +29,11 @@ export function collapseAssetUrlsForAdmin(
|
||||
value: unknown,
|
||||
baseUrl: string
|
||||
): unknown {
|
||||
const prefix = `${baseUrl.replace(/\/+$/, "")}/api/assets/`;
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
const prefix = `${base}/api/assets/`;
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith(prefix)) return `/api/assets/${value.slice(prefix.length)}`;
|
||||
return value;
|
||||
if (!value.includes(prefix)) return value;
|
||||
return value.replaceAll(prefix, "/api/assets/");
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => collapseAssetUrlsForAdmin(v, baseUrl));
|
||||
if (value != null && typeof value === "object") {
|
||||
|
||||
Reference in New Issue
Block a user