feat(frontend): preview tab (#11475)

This commit is contained in:
ls-root
2026-02-02 14:43:20 +01:00
committed by GitHub
parent b0d9864ebd
commit 4e27213df1
7 changed files with 79 additions and 51 deletions

View File

@@ -105,7 +105,7 @@ function Note({
const addNote = useCallback(() => {
setScript({
...script,
notes: [...script.notes, { text: "", type: "" }],
notes: [...script.notes, { text: "", type: "info" }],
});
}, [script, setScript]);

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { AlertColors } from "@/config/site-config";
export const InstallMethodSchema = z.object({
type: z.enum(["default", "alpine"], {
@@ -16,7 +17,9 @@ export const InstallMethodSchema = z.object({
const NoteSchema = z.object({
text: z.string().min(1, "Note text cannot be empty"),
type: z.string().min(1, "Note type cannot be empty"),
type: z.enum(Object.keys(AlertColors) as [keyof typeof AlertColors, ...(keyof typeof AlertColors)[]], {
message: `Type must be one of: ${Object.keys(AlertColors).join(", ")}`,
}),
});
export const ScriptSchema = z.object({
@@ -42,7 +45,7 @@ export const ScriptSchema = z.object({
username: z.string().nullable(),
password: z.string().nullable(),
}),
notes: z.array(NoteSchema),
notes: z.array(NoteSchema).optional().default([]),
}).refine((data) => {
if (data.disable === true && !data.disable_description) {
return false;

View File

@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fetchCategories } from "@/lib/data";
import { cn } from "@/lib/utils";
@@ -30,6 +31,7 @@ import Note from "./_components/note";
import { nord } from "react-syntax-highlighter/dist/esm/styles/hljs";
import SyntaxHighlighter from "react-syntax-highlighter";
import { ScriptItem } from "../scripts/_components/script-item";
const initialScript: Script = {
name: "",
@@ -60,6 +62,7 @@ export default function JSONGenerator() {
const [isCopied, setIsCopied] = useState(false);
const [isValid, setIsValid] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [currentTab, setCurrentTab] = useState<"json" | "preview">("json");
const [zodErrors, setZodErrors] = useState<z.ZodError | null>(null);
useEffect(() => {
@@ -68,6 +71,13 @@ export default function JSONGenerator() {
.catch((error) => console.error("Error fetching categories:", error));
}, []);
useEffect(() => {
if (!isValid && currentTab === "preview") {
setCurrentTab("json");
toast.error("Switched to JSON tab due to invalid configuration.");
}
}, [isValid, currentTab]);
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
@@ -196,7 +206,7 @@ export default function JSONGenerator() {
<Input
placeholder="Path to config file"
value={script.config_path || ""}
onChange={(e) => updateScript("config_path", e.target.value || null)}
onChange={(e) => updateScript("config_path", e.target.value || "")}
/>
</div>
<div>
@@ -323,25 +333,41 @@ export default function JSONGenerator() {
</form>
</div>
<div className="w-1/2 p-4 bg-background overflow-y-auto">
{validationAlert}
<div className="relative">
<div className="absolute right-2 top-2 flex gap-1">
<Button size="icon" variant="outline" onClick={handleCopy}>
{isCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</Button>
<Button size="icon" variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
</Button>
</div>
<Tabs
defaultValue="json"
className="w-full"
onValueChange={(value) => setCurrentTab(value as "json" | "preview")}
value={currentTab}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="json">JSON</TabsTrigger>
<TabsTrigger disabled={!isValid} value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="json" className="h-full w-full">
{validationAlert}
<div className="relative">
<div className="absolute right-2 top-2 flex gap-1">
<Button size="icon" variant="outline" onClick={handleCopy}>
{isCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</Button>
<Button size="icon" variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
</Button>
</div>
<SyntaxHighlighter
language="json"
style={nord}
className="mt-4 p-4 bg-secondary rounded shadow overflow-x-scroll"
>
{JSON.stringify(script, null, 2)}
</SyntaxHighlighter>
</div>
<SyntaxHighlighter
language="json"
style={nord}
className="mt-4 p-4 bg-secondary rounded shadow overflow-x-scroll"
>
{JSON.stringify(script, null, 2)}
</SyntaxHighlighter>
</div>
</TabsContent>
<TabsContent value="preview" className="h-full w-full">
<ScriptItem item={script} />
</TabsContent>
</Tabs>
</div>
</div>
);

View File

@@ -4,7 +4,8 @@ import { X, HelpCircle } from "lucide-react";
import { Suspense } from "react";
import Image from "next/image";
import type { AppVersion, Script } from "@/lib/types";
import type { AppVersion } from "@/lib/types";
import type { Script } from "@/app/json-editor/_schemas/schemas";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -26,7 +27,6 @@ import Alerts from "./script-items/alerts";
type ScriptItemProps = {
item: Script;
setSelectedScript: (script: string | null) => void;
};
function ScriptHeader({ item }: { item: Script }) {
@@ -135,25 +135,10 @@ function VersionInfo({ item }: { item: Script }) {
);
}
export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
export function ScriptItem({ item }: ScriptItemProps) {
return (
<div className="w-full mx-auto">
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight text-foreground/90">Selected Script</h2>
<button
onClick={closeScript}
className="rounded-full p-2 text-muted-foreground hover:bg-card/50 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="rounded-xl border border-border bg-accent/30 backdrop-blur-sm shadow-sm">
<div className="p-6 space-y-6">
<Suspense fallback={<div className="animate-pulse h-32 bg-accent/20 rounded-xl" />}>
@@ -162,7 +147,7 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
{item.disable && item.disable_description && (
<DisableDescription item={item} />
) }
)}
{!item.disable && (
<>

View File

@@ -1,6 +1,6 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, X } from "lucide-react";
import { useQueryState } from "nuqs";
import type { Category, Script } from "@/lib/types";
@@ -20,6 +20,11 @@ function ScriptContent() {
const [item, setItem] = useState<Script>();
const [latestPage, setLatestPage] = useState(1);
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
useEffect(() => {
if (selectedScript && links.length > 0) {
const script = links
@@ -53,7 +58,18 @@ function ScriptContent() {
<div className="px-4 w-full sm:max-w-[calc(100%-350px-16px)]">
{selectedScript && item
? (
<ScriptItem item={item} setSelectedScript={setSelectedScript} />
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight text-foreground/90">Selected Script</h2>
<button
onClick={closeScript}
className="rounded-full p-2 text-muted-foreground hover:bg-card/50 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<ScriptItem item={item} />
</div>
)
: (
<div className="flex w-full flex-col gap-5">

View File

@@ -119,7 +119,6 @@ function MobileSidebar() {
<p className="text-sm font-medium">Last Viewed</p>
<ScriptItem
item={lastViewedScript}
setSelectedScript={isOnScriptsPage ? setSelectedScript : setTempSelectedScript}
/>
</div>
)
@@ -131,3 +130,4 @@ function MobileSidebar() {
}
export default MobileSidebar;

View File

@@ -5,7 +5,7 @@ export type Script = {
slug: string;
categories: number[];
date_created: string;
type: "vm" | "ct" | "pve" | "addon";
type: "vm" | "ct" | "pve" | "addon" | "turnkey";
updateable: boolean;
privileged: boolean;
interface_port: number | null;
@@ -31,12 +31,10 @@ export type Script = {
username: string | null;
password: string | null;
};
notes: [
{
text: string;
type: keyof typeof AlertColors;
},
];
notes: {
text: string;
type: keyof typeof AlertColors;
}[];
};
export type Category = {