feat: templates page

This commit is contained in:
anatawa12 2025-04-25 03:18:03 +09:00
commit b91bdb92c1
No known key found for this signature in database
GPG key ID: 9CA909848B8E4EA6
6 changed files with 250 additions and 37 deletions

View file

@ -1,7 +1,12 @@
import { tc } from "@/lib/i18n";
import { Link } from "@tanstack/react-router";
type PageType = "/packages/user-packages" | "/packages/repositories";
type PageType =
| "/packages/user-packages"
| "/packages/repositories"
| "/packages/templates";
// Note: For historical reasons, templates page are under packages in route.
export function HeadingPageName({
pageType,
@ -11,7 +16,7 @@ export function HeadingPageName({
return (
<div className={"-ml-1.5"}>
<div
className={"grid grid-cols-2 gap-1.5 bg-secondary p-1 -m-1 rounded-md"}
className={"grid grid-cols-3 gap-1.5 bg-secondary p-1 -m-1 rounded-md"}
>
<HeadingButton
currentPage={pageType}
@ -25,6 +30,12 @@ export function HeadingPageName({
>
{tc("packages:user packages")}
</HeadingButton>
<HeadingButton
currentPage={pageType}
targetPage={"/packages/templates"}
>
{tc("packages:templates")}
</HeadingButton>
</div>
</div>
);
@ -40,7 +51,7 @@ function HeadingButton({
children: React.ReactNode;
}) {
const button =
"cursor-pointer py-1.5 font-bold grow-0 hover:bg-background rounded-sm text-center p-2";
"cursor-pointer px-3 py-2 font-bold grow-0 hover:bg-background rounded-sm text-center p-2";
if (currentPage === targetPage) {
return <div className={`${button} bg-background`}>{children}</div>;

View file

@ -0,0 +1,145 @@
import Loading from "@/app/-loading";
import { HeadingPageName } from "@/app/_main/packages/-tab-selector";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { type TauriProjectTemplateInfo, commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { usePrevPathName } from "@/lib/prev-page";
import {
projectTemplateCategory,
projectTemplateName,
} from "@/lib/project-template";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { CircleX } from "lucide-react";
import { Suspense, useId } from "react";
export const Route = createFileRoute("/_main/packages/templates/")({
component: RouteComponent,
});
function RouteComponent() {
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-left"
: "";
return (
<VStack>
<HNavBar
className={"shrink-0"}
leading={<HeadingPageName pageType={"/packages/templates"} />}
trailing={<Button>{tc("templates:create template")}</Button>}
/>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable className={"h-full w-full"}>
<Suspense fallback={<Loading />}>
<TemplatesTableBody />
</Suspense>
</ScrollableCardTable>
</main>
</VStack>
);
}
function TemplatesTableBody() {
const information = useSuspenseQuery({
queryKey: ["environmentProjectCreationInformation"],
queryFn: async () => await commands.environmentProjectCreationInformation(),
});
const TABLE_HEAD = [
"general:name",
"templates:category",
"", // actions
];
return (
<>
<thead>
<tr>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
{information.data.templates.map((template) => (
<TemplateRow key={template.id} template={template} />
))}
</tbody>
</>
);
}
function TemplateRow({
template,
remove,
}: {
template: TauriProjectTemplateInfo;
remove?: () => void;
}) {
const cellClass = "p-2.5";
const id = useId();
const category = projectTemplateCategory(template.id);
return (
<tr className="even:bg-secondary/30">
<td className={`${cellClass} w-full`}>
<label htmlFor={id}>
<p className="font-normal">{projectTemplateName(template)}</p>
</label>
</td>
<td className={cellClass}>
<Tooltip>
<TooltipTrigger>
<p className="font-normal">
{tc(`templates:category:${category}`)}
</p>
</TooltipTrigger>
<TooltipContent>
{tc(`templates:tooltip:category:${category}`)}
</TooltipContent>
</Tooltip>
</td>
<td className={`${cellClass} w-min`}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
className={category !== "alcom" ? "opacity-50" : ""}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{category === "alcom"
? tc("templates:tooltip:remove template")
: category === "builtin"
? tc("templates:tooltip:remove builtin template")
: category === "vcc"
? tc("templates:tooltip:remove vcc template")
: ""}
</TooltipContent>
</Tooltip>
</td>
</tr>
);
}

View file

@ -31,6 +31,11 @@ import { type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { router } from "@/lib/main";
import { pathSeparator } from "@/lib/os";
import {
type ProjectTemplateCategory,
projectTemplateCategory,
projectTemplateName,
} from "@/lib/project-template";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation, useQuery } from "@tanstack/react-query";
@ -115,11 +120,6 @@ interface ProjectCreationInformation {
projectLocation: string;
projectName: string;
}
const AVATARS_TEMPLATE_ID = "com.anatawa12.vrc-get.vrchat.avatars";
const WORLDS_TEMPLATE_ID = "com.anatawa12.vrc-get.vrchat.worlds";
const BLANK_TEMPLATE_ID = "com.anatawa12.vrc-get.blank";
const VCC_TEMPLATE_PREFIX = "com.anatawa12.vrc-get.vcc.";
const UNNAMED_TEMPLATE_PREFIX = "com.anatawa12.vrc-get.user.";
function EnteringInformation({
templates,
@ -183,24 +183,17 @@ function EnteringInformation({
const templateInputId = useId();
const unityInputId = useId();
type TemplateCategory = "builtin" | "alcom" | "vcc";
const templatesByCategory = useMemo(() => {
const byCategory: { [k in TemplateCategory]: TauriProjectTemplateInfo[] } =
{
builtin: [],
alcom: [],
vcc: [],
};
const templateCategory = (id: string): TemplateCategory => {
if (id.startsWith(VCC_TEMPLATE_PREFIX)) return "vcc";
if (id.startsWith(UNNAMED_TEMPLATE_PREFIX)) return "alcom";
if (id.startsWith("com.anatawa12.vrc-get.")) return "builtin";
return "alcom";
const byCategory: {
[k in ProjectTemplateCategory]: TauriProjectTemplateInfo[];
} = {
builtin: [],
alcom: [],
vcc: [],
};
for (const template of templates) {
byCategory[templateCategory(template.id)].push(template);
byCategory[projectTemplateCategory(template.id)].push(template);
}
return (
@ -208,7 +201,7 @@ function EnteringInformation({
["builtin", byCategory.builtin],
["alcom", byCategory.alcom],
["vcc", byCategory.vcc],
] as const
] satisfies [ProjectTemplateCategory, TauriProjectTemplateInfo[]][]
).filter((x) => x[1].length > 0);
}, [templates]);
@ -259,7 +252,7 @@ function EnteringInformation({
disabled={disabled}
key={template.id}
>
{templateName(template)}
{projectTemplateName(template)}
</SelectItem>
);
if (!template.available) {
@ -351,19 +344,6 @@ function EnteringInformation({
);
}
function templateName(template: TauriProjectTemplateInfo): React.ReactNode {
switch (template.id) {
case AVATARS_TEMPLATE_ID:
return tc("projects:template-name:avatars");
case WORLDS_TEMPLATE_ID:
return tc("projects:template-name:worlds");
case BLANK_TEMPLATE_ID:
return tc("projects:template-name:blank");
default:
return template.display_name;
}
}
function useProjectNameCheck(
projectLocation: string,
projectName: string,

View file

@ -0,0 +1,35 @@
import type { TauriProjectTemplateInfo } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import type React from "react";
const AVATARS_TEMPLATE_ID = "com.anatawa12.vrc-get.vrchat.avatars";
const WORLDS_TEMPLATE_ID = "com.anatawa12.vrc-get.vrchat.worlds";
const BLANK_TEMPLATE_ID = "com.anatawa12.vrc-get.blank";
const VCC_TEMPLATE_PREFIX = "com.anatawa12.vrc-get.vcc.";
const UNNAMED_TEMPLATE_PREFIX = "com.anatawa12.vrc-get.user.";
export function projectTemplateName(
template: TauriProjectTemplateInfo,
): React.ReactNode {
switch (template.id) {
case AVATARS_TEMPLATE_ID:
return tc("projects:template-name:avatars");
case WORLDS_TEMPLATE_ID:
return tc("projects:template-name:worlds");
case BLANK_TEMPLATE_ID:
return tc("projects:template-name:blank");
default:
return template.display_name;
}
}
export type ProjectTemplateCategory = "builtin" | "alcom" | "vcc";
export const projectTemplateCategory = (
id: string,
): ProjectTemplateCategory => {
if (id.startsWith(VCC_TEMPLATE_PREFIX)) return "vcc";
if (id.startsWith(UNNAMED_TEMPLATE_PREFIX)) return "alcom";
if (id.startsWith("com.anatawa12.vrc-get.")) return "builtin";
return "alcom";
};

View file

@ -27,6 +27,7 @@ import { Route as SetupSetupAppearanceIndexImport } from './../app/_setup/setup/
import { Route as MainSettingsLicensesIndexImport } from './../app/_main/settings/licenses/index'
import { Route as MainProjectsManageIndexImport } from './../app/_main/projects/manage/index'
import { Route as MainPackagesUserPackagesIndexImport } from './../app/_main/packages/user-packages/index'
import { Route as MainPackagesTemplatesIndexImport } from './../app/_main/packages/templates/index'
import { Route as MainPackagesRepositoriesIndexImport } from './../app/_main/packages/repositories/index'
// Create/Update Routes
@ -129,6 +130,14 @@ const MainPackagesUserPackagesIndexRoute =
getParentRoute: () => MainRouteRoute,
} as any)
const MainPackagesTemplatesIndexRoute = MainPackagesTemplatesIndexImport.update(
{
id: '/packages/templates/',
path: '/packages/templates/',
getParentRoute: () => MainRouteRoute,
} as any,
)
const MainPackagesRepositoriesIndexRoute =
MainPackagesRepositoriesIndexImport.update({
id: '/packages/repositories/',
@ -196,6 +205,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MainPackagesRepositoriesIndexImport
parentRoute: typeof MainRouteImport
}
'/_main/packages/templates/': {
id: '/_main/packages/templates/'
path: '/packages/templates'
fullPath: '/packages/templates'
preLoaderRoute: typeof MainPackagesTemplatesIndexImport
parentRoute: typeof MainRouteImport
}
'/_main/packages/user-packages/': {
id: '/_main/packages/user-packages/'
path: '/packages/user-packages'
@ -270,6 +286,7 @@ interface MainRouteRouteChildren {
MainProjectsIndexRoute: typeof MainProjectsIndexRoute
MainSettingsIndexRoute: typeof MainSettingsIndexRoute
MainPackagesRepositoriesIndexRoute: typeof MainPackagesRepositoriesIndexRoute
MainPackagesTemplatesIndexRoute: typeof MainPackagesTemplatesIndexRoute
MainPackagesUserPackagesIndexRoute: typeof MainPackagesUserPackagesIndexRoute
MainProjectsManageIndexRoute: typeof MainProjectsManageIndexRoute
MainSettingsLicensesIndexRoute: typeof MainSettingsLicensesIndexRoute
@ -281,6 +298,7 @@ const MainRouteRouteChildren: MainRouteRouteChildren = {
MainProjectsIndexRoute: MainProjectsIndexRoute,
MainSettingsIndexRoute: MainSettingsIndexRoute,
MainPackagesRepositoriesIndexRoute: MainPackagesRepositoriesIndexRoute,
MainPackagesTemplatesIndexRoute: MainPackagesTemplatesIndexRoute,
MainPackagesUserPackagesIndexRoute: MainPackagesUserPackagesIndexRoute,
MainProjectsManageIndexRoute: MainProjectsManageIndexRoute,
MainSettingsLicensesIndexRoute: MainSettingsLicensesIndexRoute,
@ -320,6 +338,7 @@ export interface FileRoutesByFullPath {
'/projects': typeof MainProjectsIndexRoute
'/settings': typeof MainSettingsIndexRoute
'/packages/repositories': typeof MainPackagesRepositoriesIndexRoute
'/packages/templates': typeof MainPackagesTemplatesIndexRoute
'/packages/user-packages': typeof MainPackagesUserPackagesIndexRoute
'/projects/manage': typeof MainProjectsManageIndexRoute
'/settings/licenses': typeof MainSettingsLicensesIndexRoute
@ -339,6 +358,7 @@ export interface FileRoutesByTo {
'/projects': typeof MainProjectsIndexRoute
'/settings': typeof MainSettingsIndexRoute
'/packages/repositories': typeof MainPackagesRepositoriesIndexRoute
'/packages/templates': typeof MainPackagesTemplatesIndexRoute
'/packages/user-packages': typeof MainPackagesUserPackagesIndexRoute
'/projects/manage': typeof MainProjectsManageIndexRoute
'/settings/licenses': typeof MainSettingsLicensesIndexRoute
@ -360,6 +380,7 @@ export interface FileRoutesById {
'/_main/projects/': typeof MainProjectsIndexRoute
'/_main/settings/': typeof MainSettingsIndexRoute
'/_main/packages/repositories/': typeof MainPackagesRepositoriesIndexRoute
'/_main/packages/templates/': typeof MainPackagesTemplatesIndexRoute
'/_main/packages/user-packages/': typeof MainPackagesUserPackagesIndexRoute
'/_main/projects/manage/': typeof MainProjectsManageIndexRoute
'/_main/settings/licenses/': typeof MainSettingsLicensesIndexRoute
@ -381,6 +402,7 @@ export interface FileRouteTypes {
| '/projects'
| '/settings'
| '/packages/repositories'
| '/packages/templates'
| '/packages/user-packages'
| '/projects/manage'
| '/settings/licenses'
@ -399,6 +421,7 @@ export interface FileRouteTypes {
| '/projects'
| '/settings'
| '/packages/repositories'
| '/packages/templates'
| '/packages/user-packages'
| '/projects/manage'
| '/settings/licenses'
@ -418,6 +441,7 @@ export interface FileRouteTypes {
| '/_main/projects/'
| '/_main/settings/'
| '/_main/packages/repositories/'
| '/_main/packages/templates/'
| '/_main/packages/user-packages/'
| '/_main/projects/manage/'
| '/_main/settings/licenses/'
@ -468,6 +492,7 @@ export const routeTree = rootRoute
"/_main/projects/",
"/_main/settings/",
"/_main/packages/repositories/",
"/_main/packages/templates/",
"/_main/packages/user-packages/",
"/_main/projects/manage/",
"/_main/settings/licenses/"
@ -504,6 +529,10 @@ export const routeTree = rootRoute
"filePath": "_main/packages/repositories/index.tsx",
"parent": "/_main"
},
"/_main/packages/templates/": {
"filePath": "_main/packages/templates/index.tsx",
"parent": "/_main"
},
"/_main/packages/user-packages/": {
"filePath": "_main/packages/user-packages/index.tsx",
"parent": "/_main"

View file

@ -364,6 +364,19 @@
"user packages:toast:package added": "The package was added successfully.",
"user packages:toast:package removed": "The package was removed successfully.",
"packages:templates": "Templates",
"templates:create template": "Create Template",
"templates:category": "Category",
"templates:category:builtin": "Builtin",
"templates:tooltip:category:builtin": "Builtin Templates are templates built by ALCOM developers and shipped with ALCOM",
"templates:category:alcom": "Custom",
"templates:tooltip:category:alcom": "Custom Templates are templates you created and you can edit with ALCOM.",
"templates:category:vcc": "VCC",
"templates:tooltip:category:vcc": "VCC Templates are templates you created for VCC manually.",
"templates:tooltip:remove template": "Remove Template",
"templates:tooltip:remove builtin template": "You cannot remove Builtin Templates.",
"templates:tooltip:remove vcc template": "You cannot remove VCC templates here. You should remove from Templates directory instead.",
// Settings Page
"settings": "Settings",