From eb7da996a7c2d617d276f296cac07a6fd5648664 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem
Date: Sun, 27 Oct 2024 12:03:14 +0000
Subject: ui: Redesign the settings page and move it to its own layout
---
.../components/dashboard/header/ProfileOptions.tsx | 2 +-
.../components/dashboard/settings/AISettings.tsx | 326 ---------------------
.../components/dashboard/settings/AddApiKey.tsx | 157 ----------
.../dashboard/settings/ApiKeySettings.tsx | 49 ----
.../dashboard/settings/ChangePassword.tsx | 133 ---------
.../components/dashboard/settings/DeleteApiKey.tsx | 55 ----
.../components/dashboard/settings/ImportExport.tsx | 263 -----------------
.../components/dashboard/settings/UserDetails.tsx | 33 ---
apps/web/components/dashboard/sidebar/AllLists.tsx | 2 +-
.../components/dashboard/sidebar/ModileSidebar.tsx | 3 +-
.../dashboard/sidebar/ModileSidebarItem.tsx | 27 --
apps/web/components/dashboard/sidebar/Sidebar.tsx | 2 +-
.../components/dashboard/sidebar/SidebarItem.tsx | 55 ----
13 files changed, 4 insertions(+), 1103 deletions(-)
delete mode 100644 apps/web/components/dashboard/settings/AISettings.tsx
delete mode 100644 apps/web/components/dashboard/settings/AddApiKey.tsx
delete mode 100644 apps/web/components/dashboard/settings/ApiKeySettings.tsx
delete mode 100644 apps/web/components/dashboard/settings/ChangePassword.tsx
delete mode 100644 apps/web/components/dashboard/settings/DeleteApiKey.tsx
delete mode 100644 apps/web/components/dashboard/settings/ImportExport.tsx
delete mode 100644 apps/web/components/dashboard/settings/UserDetails.tsx
delete mode 100644 apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
delete mode 100644 apps/web/components/dashboard/sidebar/SidebarItem.tsx
(limited to 'apps/web/components/dashboard')
diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx
index ea8c7d12..ee6cac01 100644
--- a/apps/web/components/dashboard/header/ProfileOptions.tsx
+++ b/apps/web/components/dashboard/header/ProfileOptions.tsx
@@ -62,7 +62,7 @@ export default function SidebarProfileOptions() {
-
+
User Settings
diff --git a/apps/web/components/dashboard/settings/AISettings.tsx b/apps/web/components/dashboard/settings/AISettings.tsx
deleted file mode 100644
index 0a8db147..00000000
--- a/apps/web/components/dashboard/settings/AISettings.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-"use client";
-
-import { ActionButton } from "@/components/ui/action-button";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormMessage,
-} from "@/components/ui/form";
-import { FullPageSpinner } from "@/components/ui/full-page-spinner";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { toast } from "@/components/ui/use-toast";
-import { useClientConfig } from "@/lib/clientConfig";
-import { api } from "@/lib/trpc";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Plus, Save, Trash2 } from "lucide-react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { buildImagePrompt, buildTextPrompt } from "@hoarder/shared/prompts";
-import {
- zNewPromptSchema,
- ZPrompt,
- zUpdatePromptSchema,
-} from "@hoarder/shared/types/prompts";
-
-export function PromptEditor() {
- const apiUtils = api.useUtils();
-
- const form = useForm>({
- resolver: zodResolver(zNewPromptSchema),
- defaultValues: {
- text: "",
- appliesTo: "all",
- },
- });
-
- const { mutateAsync: createPrompt, isPending: isCreating } =
- api.prompts.create.useMutation({
- onSuccess: () => {
- toast({
- description: "Prompt has been created!",
- });
- apiUtils.prompts.list.invalidate();
- },
- });
-
- return (
-
-
- );
-}
-
-export function PromptRow({ prompt }: { prompt: ZPrompt }) {
- const apiUtils = api.useUtils();
- const { mutateAsync: updatePrompt, isPending: isUpdating } =
- api.prompts.update.useMutation({
- onSuccess: () => {
- toast({
- description: "Prompt has been updated!",
- });
- apiUtils.prompts.list.invalidate();
- },
- });
- const { mutate: deletePrompt, isPending: isDeleting } =
- api.prompts.delete.useMutation({
- onSuccess: () => {
- toast({
- description: "Prompt has been deleted!",
- });
- apiUtils.prompts.list.invalidate();
- },
- });
-
- const form = useForm>({
- resolver: zodResolver(zUpdatePromptSchema),
- defaultValues: {
- promptId: prompt.id,
- text: prompt.text,
- appliesTo: prompt.appliesTo,
- },
- });
-
- return (
-
-
- );
-}
-
-export function TaggingRules() {
- const { data: prompts, isLoading } = api.prompts.list.useQuery();
-
- return (
-
-
Tagging Rules
-
- Prompts that you add here will be included as rules to the model during
- tag generation. You can view the final prompts in the prompt preview
- section.
-
- {isLoading &&
}
- {prompts && prompts.length == 0 && (
-
- You don't have any custom prompts yet.
-
- )}
- {prompts &&
- prompts.map((prompt) =>
)}
-
-
- );
-}
-
-export function PromptDemo() {
- const { data: prompts } = api.prompts.list.useQuery();
- const clientConfig = useClientConfig();
- return (
-
-
- Prompt Preview
-
-
Text Prompt
-
- {buildTextPrompt(
- clientConfig.inference.inferredTagLang,
- (prompts ?? [])
- .filter((p) => p.appliesTo == "text" || p.appliesTo == "all")
- .map((p) => p.text),
- "\n\n",
- /* context length */ 1024 /* The value here doesn't matter */,
- ).trim()}
-
-
Image Prompt
-
- {buildImagePrompt(
- clientConfig.inference.inferredTagLang,
- (prompts ?? [])
- .filter((p) => p.appliesTo == "images" || p.appliesTo == "all")
- .map((p) => p.text),
- ).trim()}
-
-
- );
-}
-
-export default function AISettings() {
- return (
- <>
-
-
- >
- );
-}
diff --git a/apps/web/components/dashboard/settings/AddApiKey.tsx b/apps/web/components/dashboard/settings/AddApiKey.tsx
deleted file mode 100644
index 34fd2df7..00000000
--- a/apps/web/components/dashboard/settings/AddApiKey.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"use client";
-
-import type { SubmitErrorHandler } from "react-hook-form";
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import CopyBtn from "@/components/ui/copy-button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-function ApiKeySuccess({ apiKey }: { apiKey: string }) {
- return (
-
-
- Note: please copy the key and store it somewhere safe. Once you close
- the dialog, you won't be able to access it again.
-
-
-
- {
- return apiKey;
- }}
- />
-
-
- );
-}
-
-function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) {
- const formSchema = z.object({
- name: z.string(),
- });
- const router = useRouter();
- const mutator = api.apiKeys.create.useMutation({
- onSuccess: (resp) => {
- onSuccess(resp.key);
- router.refresh();
- },
- onError: () => {
- toast({ description: "Something went wrong", variant: "destructive" });
- },
- });
-
- const form = useForm>({
- resolver: zodResolver(formSchema),
- });
-
- async function onSubmit(value: z.infer) {
- mutator.mutate({ name: value.name });
- }
-
- const onError: SubmitErrorHandler> = (errors) => {
- toast({
- description: Object.values(errors)
- .map((v) => v.message)
- .join("\n"),
- variant: "destructive",
- });
- };
-
- return (
-
-
- );
-}
-
-export default function AddApiKey() {
- const [key, setKey] = useState(undefined);
- const [dialogOpen, setDialogOpen] = useState(false);
- return (
-
-
- New API Key
-
-
-
-
- {key ? "Key was successfully created" : "Create API key"}
-
-
- {key ? (
-
- ) : (
-
- )}
-
-
-
-
- setKey(undefined)}
- >
- Close
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/dashboard/settings/ApiKeySettings.tsx b/apps/web/components/dashboard/settings/ApiKeySettings.tsx
deleted file mode 100644
index 4d43be7a..00000000
--- a/apps/web/components/dashboard/settings/ApiKeySettings.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { api } from "@/server/api/client";
-
-import AddApiKey from "./AddApiKey";
-import DeleteApiKey from "./DeleteApiKey";
-
-export default async function ApiKeys() {
- const keys = await api.apiKeys.list();
- return (
-
-
-
-
-
-
- Name
- Key
- Created At
- Action
-
-
-
- {keys.keys.map((k) => (
-
- {k.name}
- **_{k.keyId}_**
- {k.createdAt.toLocaleString()}
-
-
-
-
- ))}
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/dashboard/settings/ChangePassword.tsx b/apps/web/components/dashboard/settings/ChangePassword.tsx
deleted file mode 100644
index aa27f223..00000000
--- a/apps/web/components/dashboard/settings/ChangePassword.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-"use client";
-
-import type { z } from "zod";
-import { ActionButton } from "@/components/ui/action-button";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useForm } from "react-hook-form";
-
-import { zChangePasswordSchema } from "@hoarder/shared/types/users";
-
-export function ChangePassword() {
- const form = useForm>({
- resolver: zodResolver(zChangePasswordSchema),
- defaultValues: {
- currentPassword: "",
- newPassword: "",
- newPasswordConfirm: "",
- },
- });
-
- const mutator = api.users.changePassword.useMutation({
- onSuccess: () => {
- toast({ description: "Password changed successfully" });
- form.reset();
- },
- onError: (e) => {
- if (e.data?.code == "UNAUTHORIZED") {
- toast({
- description: "Your current password is incorrect",
- variant: "destructive",
- });
- } else {
- toast({ description: "Something went wrong", variant: "destructive" });
- }
- },
- });
-
- async function onSubmit(value: z.infer) {
- mutator.mutate({
- currentPassword: value.currentPassword,
- newPassword: value.newPassword,
- });
- }
-
- return (
-
-
- Change Password
-
-
-
-
- );
-}
diff --git a/apps/web/components/dashboard/settings/DeleteApiKey.tsx b/apps/web/components/dashboard/settings/DeleteApiKey.tsx
deleted file mode 100644
index e2334c44..00000000
--- a/apps/web/components/dashboard/settings/DeleteApiKey.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { ActionButton } from "@/components/ui/action-button";
-import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
-import { Button } from "@/components/ui/button";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc";
-import { Trash } from "lucide-react";
-
-export default function DeleteApiKey({
- name,
- id,
-}: {
- name: string;
- id: string;
-}) {
- const router = useRouter();
- const mutator = api.apiKeys.revoke.useMutation({
- onSuccess: () => {
- toast({
- description: "Key was successfully deleted",
- });
- router.refresh();
- },
- });
-
- return (
-
- Are you sure you want to delete the API key "{name}"? Any
- service using this API key will lose access.
-
- }
- actionButton={(setDialogOpen) => (
-
- mutator.mutate({ id }, { onSuccess: () => setDialogOpen(false) })
- }
- >
- Delete
-
- )}
- >
-
-
-
-
- );
-}
diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx
deleted file mode 100644
index 1145a42d..00000000
--- a/apps/web/components/dashboard/settings/ImportExport.tsx
+++ /dev/null
@@ -1,263 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { buttonVariants } from "@/components/ui/button";
-import FilePickerButton from "@/components/ui/file-picker-button";
-import { Progress } from "@/components/ui/progress";
-import { toast } from "@/components/ui/use-toast";
-import {
- ParsedBookmark,
- parseHoarderBookmarkFile,
- parseNetscapeBookmarkFile,
- parsePocketBookmarkFile,
-} from "@/lib/importBookmarkParser";
-import { cn } from "@/lib/utils";
-import { useMutation } from "@tanstack/react-query";
-import { TRPCClientError } from "@trpc/client";
-import { Download, Upload } from "lucide-react";
-
-import {
- useCreateBookmarkWithPostHook,
- useUpdateBookmark,
- useUpdateBookmarkTags,
-} from "@hoarder/shared-react/hooks/bookmarks";
-import {
- useAddBookmarkToList,
- useCreateBookmarkList,
-} from "@hoarder/shared-react/hooks/lists";
-import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
-
-export function ExportButton() {
- return (
-
-
- Export Links and Notes
-
- );
-}
-
-export function ImportExportRow() {
- const router = useRouter();
-
- const [importProgress, setImportProgress] = useState<{
- done: number;
- total: number;
- } | null>(null);
-
- const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook();
- const { mutateAsync: updateBookmark } = useUpdateBookmark();
- const { mutateAsync: createList } = useCreateBookmarkList();
- const { mutateAsync: addToList } = useAddBookmarkToList();
- const { mutateAsync: updateTags } = useUpdateBookmarkTags();
-
- const { mutateAsync: parseAndCreateBookmark } = useMutation({
- mutationFn: async (toImport: {
- bookmark: ParsedBookmark;
- listId: string;
- }) => {
- const bookmark = toImport.bookmark;
- if (bookmark.content === undefined) {
- throw new Error("Content is undefined");
- }
- const created = await createBookmark(
- bookmark.content.type === BookmarkTypes.LINK
- ? {
- type: BookmarkTypes.LINK,
- url: bookmark.content.url,
- }
- : {
- type: BookmarkTypes.TEXT,
- text: bookmark.content.text,
- },
- );
-
- await Promise.all([
- // Update title and createdAt if they're set
- bookmark.title.length > 0 || bookmark.addDate
- ? updateBookmark({
- bookmarkId: created.id,
- title: bookmark.title,
- createdAt: bookmark.addDate
- ? new Date(bookmark.addDate * 1000)
- : undefined,
- note: bookmark.notes,
- }).catch(() => {
- /* empty */
- })
- : undefined,
-
- // Add to import list
- addToList({
- bookmarkId: created.id,
- listId: toImport.listId,
- }).catch((e) => {
- if (
- e instanceof TRPCClientError &&
- e.message.includes("already in the list")
- ) {
- /* empty */
- } else {
- throw e;
- }
- }),
-
- // Update tags
- bookmark.tags.length > 0
- ? updateTags({
- bookmarkId: created.id,
- attach: bookmark.tags.map((t) => ({ tagName: t })),
- detach: [],
- })
- : undefined,
- ]);
- return created;
- },
- });
-
- const { mutateAsync: runUploadBookmarkFile } = useMutation({
- mutationFn: async ({
- file,
- source,
- }: {
- file: File;
- source: "html" | "pocket" | "hoarder";
- }) => {
- if (source === "html") {
- return await parseNetscapeBookmarkFile(file);
- } else if (source === "pocket") {
- return await parsePocketBookmarkFile(file);
- } else if (source === "hoarder") {
- return await parseHoarderBookmarkFile(file);
- } else {
- throw new Error("Unknown source");
- }
- },
- onSuccess: async (resp) => {
- const importList = await createList({
- name: `Imported Bookmarks`,
- icon: "⬆️",
- });
- setImportProgress({ done: 0, total: resp.length });
-
- const successes = [];
- const failed = [];
- const alreadyExisted = [];
- // Do the imports one by one
- for (const parsedBookmark of resp) {
- try {
- const result = await parseAndCreateBookmark({
- bookmark: parsedBookmark,
- listId: importList.id,
- });
- if (result.alreadyExists) {
- alreadyExisted.push(parsedBookmark);
- } else {
- successes.push(parsedBookmark);
- }
- } catch (e) {
- failed.push(parsedBookmark);
- }
- setImportProgress((prev) => ({
- done: (prev?.done ?? 0) + 1,
- total: resp.length,
- }));
- }
-
- if (successes.length > 0 || alreadyExisted.length > 0) {
- toast({
- description: `Imported ${successes.length} bookmarks and skipped ${alreadyExisted.length} bookmarks that already existed`,
- variant: "default",
- });
- }
-
- if (failed.length > 0) {
- toast({
- description: `Failed to import ${failed.length} bookmarks`,
- variant: "destructive",
- });
- }
-
- router.push(`/dashboard/lists/${importList.id}`);
- },
- onError: (error) => {
- toast({
- description: error.message,
- variant: "destructive",
- });
- },
- });
-
- return (
-
-
-
- runUploadBookmarkFile({ file, source: "html" })
- }
- >
-
- Import Bookmarks from HTML file
-
-
-
- runUploadBookmarkFile({ file, source: "pocket" })
- }
- >
-
- Import Bookmarks from Pocket export
-
-
- runUploadBookmarkFile({ file, source: "hoarder" })
- }
- >
-
- Import Bookmarks from Hoarder export
-
-
-
- {importProgress && (
-
-
- Processed {importProgress.done} of {importProgress.total} bookmarks
-
-
-
- )}
-
- );
-}
-
-export default function ImportExport() {
- return (
-
-
Import / Export Bookmarks
-
-
- );
-}
diff --git a/apps/web/components/dashboard/settings/UserDetails.tsx b/apps/web/components/dashboard/settings/UserDetails.tsx
deleted file mode 100644
index 471a6e09..00000000
--- a/apps/web/components/dashboard/settings/UserDetails.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Input } from "@/components/ui/input";
-import { api } from "@/server/api/client";
-
-export default async function UserDetails() {
- const whoami = await api.users.whoami();
-
- const details = [
- {
- label: "Name",
- value: whoami.name ?? undefined,
- },
- {
- label: "Email",
- value: whoami.email ?? undefined,
- },
- ];
-
- return (
-
-
- Basic Details
-
-
- {details.map(({ label, value }) => (
-
- ))}
-
-
- );
-}
diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx
index b6cadea9..c48ddb0f 100644
--- a/apps/web/components/dashboard/sidebar/AllLists.tsx
+++ b/apps/web/components/dashboard/sidebar/AllLists.tsx
@@ -3,6 +3,7 @@
import { useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { Button } from "@/components/ui/button";
import { CollapsibleTriggerTriangle } from "@/components/ui/collapsible";
import { MoreHorizontal, Plus } from "lucide-react";
@@ -13,7 +14,6 @@ import { ZBookmarkListTreeNode } from "@hoarder/shared/utils/listUtils";
import { CollapsibleBookmarkLists } from "../lists/CollapsibleBookmarkLists";
import { EditListModal } from "../lists/EditListModal";
import { ListOptions } from "../lists/ListOptions";
-import SidebarItem from "./SidebarItem";
export default function AllLists({
initialData,
diff --git a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
index 7ccf6b8d..bfa91afa 100644
--- a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
+++ b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
@@ -1,8 +1,7 @@
+import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem";
import HoarderLogoIcon from "@/public/icons/logo-icon.svg";
import { ClipboardList, Search, Tag } from "lucide-react";
-import MobileSidebarItem from "./ModileSidebarItem";
-
export default async function MobileSidebar() {
return (
diff --git a/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx b/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
deleted file mode 100644
index 4d3436ea..00000000
--- a/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { cn } from "@/lib/utils";
-
-export default function MobileSidebarItem({
- logo,
- path,
-}: {
- logo: React.ReactNode;
- path: string;
-}) {
- const currentPath = usePathname();
- return (
-
-
- {logo}
-
-
- );
-}
diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx
index 14d019ff..8021ad36 100644
--- a/apps/web/components/dashboard/sidebar/Sidebar.tsx
+++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
+import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
@@ -7,7 +8,6 @@ import { Archive, Home, Search, Tag } from "lucide-react";
import serverConfig from "@hoarder/shared/config";
import AllLists from "./AllLists";
-import SidebarItem from "./SidebarItem";
export default async function Sidebar() {
const session = await getServerAuthSession();
diff --git a/apps/web/components/dashboard/sidebar/SidebarItem.tsx b/apps/web/components/dashboard/sidebar/SidebarItem.tsx
deleted file mode 100644
index 83ce776e..00000000
--- a/apps/web/components/dashboard/sidebar/SidebarItem.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { cn } from "@/lib/utils";
-
-export default function SidebarItem({
- name,
- logo,
- path,
- className,
- linkClassName,
- style,
- collapseButton,
- right = null,
-}: {
- name: string;
- logo: React.ReactNode;
- path: string;
- style?: React.CSSProperties;
- className?: string;
- linkClassName?: string;
- right?: React.ReactNode;
- collapseButton?: React.ReactNode;
-}) {
- const currentPath = usePathname();
- return (
-
- {collapseButton}
-
-
-
- {logo}
- {name}
-
- {right}
-
-
-
- );
-}
--
cgit v1.2.3-70-g09d2