refact: mult levels dashboard sidebar
This commit is contained in:
@@ -92,7 +92,7 @@ export default function AppConfigs({}: {}) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible className="group">
|
||||
<Collapsible className="group" defaultOpen>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
||||
<div className="text-lg font-bold">{t("App Configs")}</div>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
|
||||
11
app/(protected)/admin/system/domains/loading.tsx
Normal file
11
app/(protected)/admin/system/domains/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SystemSettingsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
app/(protected)/admin/system/domains/page.tsx
Normal file
38
app/(protected)/admin/system/domains/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import DomainList from "../domain-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "System Settings",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/domain"
|
||||
/>
|
||||
<p className="rounded-md border border-dashed bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<strong>Note</strong>: Once the domain is bound to the project, it will
|
||||
be used as a business domain to provide services, and direct access to
|
||||
the business domain will redirect to the main site you deploy.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,28 +24,6 @@ export default async function DashboardPage() {
|
||||
<DashboardHeader heading="System Settings" text="" />
|
||||
<AppConfigs />
|
||||
<S3Configs />
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/domain"
|
||||
/>
|
||||
<PlanList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/plan"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
11
app/(protected)/admin/system/plans/loading.tsx
Normal file
11
app/(protected)/admin/system/plans/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SystemSettingsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
app/(protected)/admin/system/plans/page.tsx
Normal file
33
app/(protected)/admin/system/plans/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import PlanList from "../plan-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "System Settings",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlanList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/plan"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -579,21 +579,7 @@ export const CodeLight = ({ content }: { content: string }) => {
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* Code content */}
|
||||
<span className="text-blue-400">
|
||||
{line
|
||||
.replace(
|
||||
/function/,
|
||||
(match) => `<span class="text-purple-400">${match}</span>`,
|
||||
)
|
||||
.replace(
|
||||
/"[^"]*"/,
|
||||
(match) => `<span class="text-green-400">${match}</span>`,
|
||||
)
|
||||
.replace(
|
||||
/console/,
|
||||
(match) => `<span class="text-yellow-400">${match}</span>`,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-blue-400">{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
|
||||
9
app/(protected)/dashboard/urls/analytics/loading.tsx
Normal file
9
app/(protected)/dashboard/urls/analytics/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
app/(protected)/dashboard/urls/analytics/page.tsx
Normal file
24
app/(protected)/dashboard/urls/analytics/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import Globe from "../globe";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Globe Analytics",
|
||||
description: "Display link's globe analytics.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Globe />
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
app/(protected)/dashboard/urls/api/loading.tsx
Normal file
10
app/(protected)/dashboard/urls/api/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-[120px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
app/(protected)/dashboard/urls/api/page.tsx
Normal file
47
app/(protected)/dashboard/urls/api/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
|
||||
import { CodeLight } from "../../scrape/scrapes";
|
||||
import LiveLog from "../live-logs";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Live Logs",
|
||||
description: "Display link's real-time live logs.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
<CodeLight
|
||||
content={`
|
||||
curl -X POST \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "wrdo-api-key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"target": "https://www.oiov.dev",
|
||||
"url": "abc123",
|
||||
"expiration": "-1",
|
||||
"prefix": "wr.do",
|
||||
"visible": 1,
|
||||
"active": 1,
|
||||
"password": ""
|
||||
}' \\
|
||||
https://wr.do/api/v1/short
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,10 +47,16 @@ export interface LogEntry {
|
||||
isNew?: boolean; // New property to track newly added logs
|
||||
}
|
||||
|
||||
export default function LiveLog({ admin = false }: { admin?: boolean }) {
|
||||
export default function LiveLog({
|
||||
admin = false,
|
||||
live = false,
|
||||
}: {
|
||||
admin?: boolean;
|
||||
live?: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const [isLive, setIsLive] = useState(live);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [limitDiplay, setLimitDisplay] = useState(100);
|
||||
const newLogsRef = useRef<Set<string>>(new Set()); // Track new log keys
|
||||
|
||||
11
app/(protected)/dashboard/urls/logs/loading.tsx
Normal file
11
app/(protected)/dashboard/urls/logs/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Live Logs" text="" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
app/(protected)/dashboard/urls/logs/page.tsx
Normal file
25
app/(protected)/dashboard/urls/logs/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import LiveLog from "../live-logs";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Live Logs",
|
||||
description: "Display link's real-time live logs.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Live Logs" text="" />
|
||||
<LiveLog live={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -647,17 +647,6 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
</>
|
||||
);
|
||||
|
||||
const rendLogs = () => (
|
||||
<div className="mt-6 space-y-3">
|
||||
{action.indexOf("admin") > -1 ? <LiveLog admin={true} /> : <LiveLog />}
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
@@ -678,13 +667,6 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
<Icons.layoutGrid className="size-4" />
|
||||
{/* Grid */}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
onClick={() => setCurrentView("Realtime")}
|
||||
value="Realtime"
|
||||
>
|
||||
<Icons.globe className="size-4 text-blue-500" />
|
||||
{/* Realtime */}
|
||||
</TabsTrigger>
|
||||
{selectedUrl?.id && (
|
||||
<TabsTrigger
|
||||
className="flex items-center gap-1 text-muted-foreground"
|
||||
@@ -732,16 +714,11 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
{pathname !== "/dashboard" && <UrlStatus action={action} />}
|
||||
{rendeSeachInputs()}
|
||||
{rendeList()}
|
||||
{rendLogs()}
|
||||
</TabsContent>
|
||||
<TabsContent className="space-y-3" value="Grid">
|
||||
{pathname !== "/dashboard" && <UrlStatus action={action} />}
|
||||
{rendeSeachInputs()}
|
||||
{rendeGrid()}
|
||||
{rendLogs()}
|
||||
</TabsContent>
|
||||
<TabsContent value="Realtime">
|
||||
{action.indexOf("admin") > -1 ? <Globe isAdmin={true} /> : <Globe />}
|
||||
</TabsContent>
|
||||
{selectedUrl?.id && (
|
||||
<TabsContent value={selectedUrl.id}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "All-in-one domain platform with short links, temp email, subdomain management, file storage, and open APIs.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "1.1.6",
|
||||
"versionName": "1.1.7",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SidebarNavItem } from "@/types";
|
||||
import { NavItem, SidebarNavItem } from "@/types";
|
||||
import { Menu, PanelLeftClose, PanelRightClose } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "next-view-transitions";
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
links: SidebarNavItem[];
|
||||
}
|
||||
@@ -34,18 +40,183 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
|
||||
|
||||
const { isTablet } = useMediaQuery();
|
||||
const [isSidebarExpanded, setIsSidebarExpanded] = useState(!isTablet);
|
||||
const [openCollapsibles, setOpenCollapsibles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarExpanded(!isSidebarExpanded);
|
||||
};
|
||||
|
||||
const toggleCollapsible = (itemTitle: string) => {
|
||||
setOpenCollapsibles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemTitle)) {
|
||||
newSet.delete(itemTitle);
|
||||
} else {
|
||||
newSet.add(itemTitle);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsSidebarExpanded(!isTablet);
|
||||
}, [isTablet]);
|
||||
|
||||
// Auto-open collapsibles that contain the current path
|
||||
useEffect(() => {
|
||||
links.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
if (item.items) {
|
||||
const hasActivePath = item.items.some(
|
||||
(subItem) => subItem.href === path,
|
||||
);
|
||||
if (hasActivePath) {
|
||||
setOpenCollapsibles((prev) => new Set(prev).add(item.title));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [path, links]);
|
||||
|
||||
const renderNavItem = (item: NavItem, isNested = false) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : () => null;
|
||||
const hasSubItems = item.items && item.items.length > 0;
|
||||
const isOpen = openCollapsibles.has(item.title);
|
||||
|
||||
// Item with sub-items (collapsible)
|
||||
if (hasSubItems) {
|
||||
return (
|
||||
<Fragment key={`nav-item-${item.title}`}>
|
||||
{isSidebarExpanded ? (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={() => toggleCollapsible(item.title)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
|
||||
"text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{t(item.title)}
|
||||
<Icons.chevronDown
|
||||
className={cn(
|
||||
"ml-auto size-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-4 pt-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{item.items!.map((subItem) => renderNavItem(subItem, true))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<Tooltip key={`tooltip-${item.title}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md py-2 text-sm font-medium hover:bg-muted",
|
||||
"text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-full items-center justify-center">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.items!.map((subItem) =>
|
||||
subItem.disabled ? (
|
||||
<span className="cursor-pointer text-muted-foreground">
|
||||
{t(subItem.title)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={subItem.title}
|
||||
href={subItem.href || "#"}
|
||||
className="hover:underline"
|
||||
>
|
||||
{t(subItem.title)}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular link item
|
||||
if (item.href) {
|
||||
return (
|
||||
<Fragment key={`link-fragment-${item.title}`}>
|
||||
{isSidebarExpanded ? (
|
||||
<Link
|
||||
key={`link-${item.title}`}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
|
||||
path === item.href
|
||||
? "bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
isNested && "pl-6",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{t(item.title)}
|
||||
{item.badge && (
|
||||
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<Tooltip key={`tooltip-${item.title}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
key={`link-tooltip-${item.title}`}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md py-2 text-sm font-medium hover:bg-muted",
|
||||
path === item.href
|
||||
? "bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-full items-center justify-center">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(item.title)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="sticky top-0 h-full">
|
||||
<div className="sticky top-0 z-[40] h-full">
|
||||
<ScrollArea className="h-full overflow-y-auto border-r">
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -55,8 +226,6 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
|
||||
>
|
||||
<div className="flex h-full max-h-screen flex-1 flex-col gap-2">
|
||||
<div className="flex h-14 items-center gap-2 p-4 lg:h-[60px]">
|
||||
{/* {isSidebarExpanded ? <ProjectSwitcher /> : null} */}
|
||||
|
||||
{isSidebarExpanded && (
|
||||
<>
|
||||
<Icons.logo />
|
||||
@@ -105,61 +274,7 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
|
||||
) : (
|
||||
<div className="h-4" />
|
||||
)}
|
||||
{section.items.map((item) => {
|
||||
const Icon = Icons[item.icon || "arrowRight"];
|
||||
return (
|
||||
item.href && (
|
||||
<Fragment key={`link-fragment-${item.title}`}>
|
||||
{isSidebarExpanded ? (
|
||||
<Link
|
||||
key={`link-${item.title}`}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
|
||||
path === item.href
|
||||
? "bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{t(item.title)}
|
||||
{item.badge && (
|
||||
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<Tooltip key={`tooltip-${item.title}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
key={`link-tooltip-${item.title}`}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md py-2 text-sm font-medium hover:bg-muted",
|
||||
path === item.href
|
||||
? "bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-full items-center justify-center">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t(item.title)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{section.items.map((item) => renderNavItem(item))}
|
||||
</section>
|
||||
),
|
||||
)}
|
||||
@@ -200,9 +315,114 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
|
||||
export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
|
||||
const path = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openCollapsibles, setOpenCollapsibles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const { isSm, isMobile } = useMediaQuery();
|
||||
const t = useTranslations("System");
|
||||
|
||||
const toggleCollapsible = (itemTitle: string) => {
|
||||
setOpenCollapsibles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemTitle)) {
|
||||
newSet.delete(itemTitle);
|
||||
} else {
|
||||
newSet.add(itemTitle);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-open collapsibles that contain the current path
|
||||
useEffect(() => {
|
||||
links.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
if (item.items) {
|
||||
const hasActivePath = item.items.some(
|
||||
(subItem) => subItem.href === path,
|
||||
);
|
||||
if (hasActivePath) {
|
||||
setOpenCollapsibles((prev) => new Set(prev).add(item.title));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [path, links]);
|
||||
|
||||
const renderMobileNavItem = (item: NavItem, isNested = false) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : () => null;
|
||||
const hasSubItems = item.items && item.items.length > 0;
|
||||
const isOpen = openCollapsibles.has(item.title);
|
||||
|
||||
// Item with sub-items (collapsible)
|
||||
if (hasSubItems) {
|
||||
return (
|
||||
<Collapsible
|
||||
key={`nav-item-${item.title}`}
|
||||
open={isOpen}
|
||||
onOpenChange={() => toggleCollapsible(item.title)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
|
||||
"text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{t(item.title)}
|
||||
<Icons.chevronDown
|
||||
className={cn(
|
||||
"ml-auto size-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-4 pt-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{item.items!.map((subItem) => renderMobileNavItem(subItem, true))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular link item
|
||||
if (item.href) {
|
||||
return (
|
||||
<Fragment key={`link-fragment-${item.title}`}>
|
||||
<Link
|
||||
key={`link-${item.title}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled) setOpen(false);
|
||||
}}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
|
||||
path === item.href
|
||||
? "bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
isNested && "pl-6",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{t(item.title)}
|
||||
{item.badge && (
|
||||
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isSm || isMobile) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -224,7 +444,6 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
{/* <Icons.logo /> */}
|
||||
<Image src="/favicon.ico" alt="logo" width={20} height={20} />
|
||||
<span
|
||||
style={{ fontFamily: "Bahamas Bold" }}
|
||||
@@ -245,38 +464,7 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
|
||||
{t(section.title)}
|
||||
</p>
|
||||
|
||||
{section.items.map((item) => {
|
||||
const Icon = Icons[item.icon || "arrowRight"];
|
||||
return (
|
||||
item.href && (
|
||||
<Fragment key={`link-fragment-${item.title}`}>
|
||||
<Link
|
||||
key={`link-${item.title}`}
|
||||
onClick={() => {
|
||||
if (!item.disabled) setOpen(false);
|
||||
}}
|
||||
href={item.disabled ? "#" : item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
|
||||
path === item.href
|
||||
? "bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{t(item.title)}
|
||||
{item.badge && (
|
||||
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{section.items.map((item) => renderMobileNavItem(item))}
|
||||
</section>
|
||||
),
|
||||
)}
|
||||
@@ -303,10 +491,6 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
|
||||
v{pkg.version}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-auto">
|
||||
<UpgradeCard />
|
||||
</div> */}
|
||||
</nav>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
BotMessageSquare,
|
||||
Boxes,
|
||||
Braces,
|
||||
Bug,
|
||||
Calendar,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
Inbox,
|
||||
Info,
|
||||
Laptop,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
LayoutPanelLeft,
|
||||
Link,
|
||||
@@ -115,7 +117,9 @@ export const Icons = {
|
||||
scanQrCode: ScanQrCode,
|
||||
monitorDown: MonitorDown,
|
||||
shieldCheck: ShieldCheck,
|
||||
layers: Layers,
|
||||
databaseZap: DatabaseZap,
|
||||
boxes: Boxes,
|
||||
cloudUpload: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
role="presentation"
|
||||
|
||||
@@ -61,7 +61,7 @@ export function Modal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
|
||||
<Drawer.Overlay className="fixed inset-0 z-[40] bg-background/80 backdrop-blur-sm" />
|
||||
<Drawer.Portal>
|
||||
<Drawer.Content
|
||||
className={cn(
|
||||
|
||||
@@ -9,13 +9,42 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "MENU",
|
||||
items: [
|
||||
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
|
||||
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
|
||||
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
|
||||
{ href: "/emails", icon: "mail", title: "Emails" },
|
||||
{
|
||||
href: "/dashboard/storage",
|
||||
href: "",
|
||||
icon: "link",
|
||||
title: "Short Urls",
|
||||
items: [
|
||||
{ href: "/dashboard/urls", title: "Links" },
|
||||
{ href: "/dashboard/urls/analytics", title: "Analytics" },
|
||||
{ href: "/dashboard/urls/logs", title: "Ip Logs" },
|
||||
{ href: "/dashboard/urls/api", title: "API" },
|
||||
],
|
||||
},
|
||||
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
|
||||
{
|
||||
href: "",
|
||||
icon: "mail",
|
||||
title: "Emails",
|
||||
items: [
|
||||
{ href: "/emails", title: "Inbox" },
|
||||
{ href: "/emails/sent", title: "Sent", disabled: true },
|
||||
{ href: "/emails/trash", title: "Trash", disabled: true },
|
||||
{ href: "/emails/api", title: "API", disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "",
|
||||
icon: "storage",
|
||||
title: "Cloud Storage",
|
||||
items: [
|
||||
{ href: "/dashboard/storage", title: "Storage" },
|
||||
{
|
||||
href: "/dashboard/storage/analytics",
|
||||
title: "File Analytics",
|
||||
disabled: true,
|
||||
},
|
||||
{ href: "/dashboard/storage/api", title: "API", disabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -24,28 +53,31 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
items: [
|
||||
{
|
||||
href: "/dashboard/scrape",
|
||||
icon: "bug",
|
||||
icon: "layers",
|
||||
title: "Overview",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/screenshot",
|
||||
icon: "camera",
|
||||
title: "Screenshot",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/qrcode",
|
||||
icon: "qrcode",
|
||||
title: "QR Code",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/meta-info",
|
||||
icon: "globe",
|
||||
title: "Meta Info",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/markdown",
|
||||
icon: "fileText",
|
||||
title: "Markdown",
|
||||
href: "",
|
||||
icon: "bug",
|
||||
title: "APIs",
|
||||
items: [
|
||||
{
|
||||
href: "/dashboard/scrape/screenshot",
|
||||
title: "Screenshot",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/qrcode",
|
||||
title: "QR Code",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/meta-info",
|
||||
title: "Meta Info",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/markdown",
|
||||
title: "Markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -58,35 +90,60 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "Admin Panel",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
|
||||
{
|
||||
href: "/admin/users",
|
||||
icon: "users",
|
||||
title: "Users",
|
||||
href: "/admin/resources",
|
||||
icon: "boxes",
|
||||
title: "Resources",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/users",
|
||||
title: "Users",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/urls",
|
||||
// icon: "link",
|
||||
title: "URLs",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/records",
|
||||
// icon: "globe",
|
||||
title: "Records",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/storage",
|
||||
// icon: "storage",
|
||||
title: "Cloud Storage Manage",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/urls",
|
||||
icon: "link",
|
||||
title: "URLs",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/records",
|
||||
icon: "globe",
|
||||
title: "Records",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/storage",
|
||||
icon: "storage",
|
||||
title: "Cloud Storage Manage",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/system",
|
||||
href: "",
|
||||
icon: "settings",
|
||||
title: "System Settings",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/system",
|
||||
title: "App Configs",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/system/domains",
|
||||
title: "Domains",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/system/plans",
|
||||
title: "Plans",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -591,7 +591,21 @@
|
||||
"Log out": "Log out",
|
||||
"System Settings": "System Settings",
|
||||
"Cloud Storage": "Cloud Storage",
|
||||
"Cloud Storage Manage": "Cloud Storage"
|
||||
"Cloud Storage Manage": "Cloud Storage",
|
||||
"Inbox": "Inbox",
|
||||
"Sent": "Sent",
|
||||
"Spam": "Spam",
|
||||
"Trash": "Trash",
|
||||
"API": "API",
|
||||
"Links": "Links",
|
||||
"Analytics": "Analytics",
|
||||
"Ip Logs": "Ip Logs",
|
||||
"APIs": "APIs",
|
||||
"File Analytics": "File Analytics",
|
||||
"Storage": "Storage",
|
||||
"Resources": "Resources",
|
||||
"App Configs": "App Settings",
|
||||
"Plans": "Plan Settings"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "Search emails",
|
||||
|
||||
@@ -565,9 +565,9 @@
|
||||
"WRoom": "聊天室",
|
||||
"OPEN API": "开放API",
|
||||
"Overview": "概览面板",
|
||||
"Screenshot": "截图API",
|
||||
"QR Code": "二维码API",
|
||||
"Meta Info": "元数据API",
|
||||
"Screenshot": "网页截图",
|
||||
"QR Code": "网页二维码",
|
||||
"Meta Info": "元数据",
|
||||
"Markdown": "Markdown",
|
||||
"ADMIN": "管理员",
|
||||
"Admin Panel": "概览面板",
|
||||
@@ -590,7 +590,21 @@
|
||||
"Log out": "退出登录",
|
||||
"System Settings": "系统设置",
|
||||
"Cloud Storage": "云存储",
|
||||
"Cloud Storage Manage": "云存储管理"
|
||||
"Cloud Storage Manage": "云存储管理",
|
||||
"Inbox": "收件箱",
|
||||
"Sent": "已发送",
|
||||
"Spam": "垃圾邮件",
|
||||
"Trash": "废纸篓",
|
||||
"API": "API",
|
||||
"Links": "我的链接",
|
||||
"Analytics": "访客统计",
|
||||
"Ip Logs": "实时日志",
|
||||
"APIs": "APIs",
|
||||
"File Analytics": "文件统计",
|
||||
"Storage": "存储桶",
|
||||
"Resources": "资源管理",
|
||||
"App Configs": "全局配置",
|
||||
"Plans": "配额设置"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "搜索邮箱...",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "All-in-one domain platform with short links, temp email, subdomain management, file storage, and open APIs.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "1.1.6",
|
||||
"versionName": "1.1.7",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "All-in-one domain platform with short links, temp email, subdomain management, file storage, and open APIs.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "1.1.6",
|
||||
"versionName": "1.1.7",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
types/index.d.ts
vendored
1
types/index.d.ts
vendored
@@ -27,6 +27,7 @@ export type NavItem = {
|
||||
external?: boolean;
|
||||
authorizeOnly?: UserRole;
|
||||
icon?: keyof typeof Icons;
|
||||
items?: NavItem[];
|
||||
};
|
||||
|
||||
export type MainNavItem = NavItem;
|
||||
|
||||
Reference in New Issue
Block a user