Compare commits
31 Commits
main
...
feat/lsp-s
Author | SHA1 | Date | |
---|---|---|---|
f0a2010985 | |||
1afa7a1a5d | |||
a9ddf03e93 | |||
22225ed042 | |||
40f273f5a2 | |||
f590d77578 | |||
668145a9c7 | |||
ac7e34dfc5 | |||
2cdd9adf3e | |||
20d790c5e0 | |||
08831481b8 | |||
a872a33052 | |||
1aba2bdc0f | |||
9b2177fea3 | |||
b6a4c3182e | |||
ae662bff78 | |||
8d2df7dc4a | |||
cd842b3bdd | |||
e5b8848b8f | |||
301490c30b | |||
52a7ee5d0b | |||
b69cc453e8 | |||
5ffbecefc2 | |||
93798b5a7e | |||
ed547464c1 | |||
8808b4fb9b | |||
398c56b38b | |||
bb2093d14f | |||
5f427a5032 | |||
474c71bf96 | |||
c8a9b25e27 |
@ -1,3 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
"@typescript-eslint/no-empty-object-type": "warn",
|
||||||
|
"@typescript-eslint/no-require-imports": "warn"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -44,3 +44,6 @@ cache
|
|||||||
|
|
||||||
# bun
|
# bun
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode
|
||||||
|
40
messages/en-US.json
Normal file
40
messages/en-US.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"Page": {
|
||||||
|
"DashboardPage": {
|
||||||
|
"title": "Welcome to Judge4c!"
|
||||||
|
},
|
||||||
|
"PlaygroundPage": {
|
||||||
|
"Components": {
|
||||||
|
"playgroundSidebar": {
|
||||||
|
"title": "Files"
|
||||||
|
},
|
||||||
|
"ligaturesCheckbox": {
|
||||||
|
"label": "Font Ligatures"
|
||||||
|
},
|
||||||
|
"minimapCheckbox": {
|
||||||
|
"label": "Open Minimap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Components": {
|
||||||
|
"NavUser": {
|
||||||
|
"profile": "Profile",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"settings": "Settings",
|
||||||
|
"logout": "Log out",
|
||||||
|
"login": "Log in"
|
||||||
|
},
|
||||||
|
"NavProjects": {
|
||||||
|
"projects": "Projects",
|
||||||
|
"viewProject": "View Project",
|
||||||
|
"shareProject": "Share Project",
|
||||||
|
"deleteProject": "Delete Project",
|
||||||
|
"more": "More"
|
||||||
|
},
|
||||||
|
"NavSecondary": {
|
||||||
|
"support": "Support",
|
||||||
|
"feedback": "Feedback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
messages/zh-CN.json
Normal file
40
messages/zh-CN.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"Page": {
|
||||||
|
"DashboardPage": {
|
||||||
|
"title": "欢迎使用Judge4c!"
|
||||||
|
},
|
||||||
|
"PlaygroundPage": {
|
||||||
|
"Components": {
|
||||||
|
"playgroundSidebar": {
|
||||||
|
"title": "文件"
|
||||||
|
},
|
||||||
|
"ligaturesCheckbox": {
|
||||||
|
"label": "连字"
|
||||||
|
},
|
||||||
|
"minimapCheckbox": {
|
||||||
|
"label": "打开缩略图"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Components": {
|
||||||
|
"NavUser": {
|
||||||
|
"profile": "个人资料",
|
||||||
|
"notifications": "通知",
|
||||||
|
"settings": "设置",
|
||||||
|
"logout": "登出",
|
||||||
|
"login": "登录"
|
||||||
|
},
|
||||||
|
"NavProjects": {
|
||||||
|
"projects": "项目",
|
||||||
|
"viewProject": "查看项目",
|
||||||
|
"shareProject": "分享项目",
|
||||||
|
"deleteProject": "删除项目",
|
||||||
|
"more": "更多"
|
||||||
|
},
|
||||||
|
"NavSecondary": {
|
||||||
|
"support": "支持",
|
||||||
|
"feedback": "反馈"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const withNextIntl = createNextIntlPlugin();
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
|
36
package.json
36
package.json
@ -9,25 +9,51 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/fira-code": "^5.1.0",
|
||||||
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cross-fetch": "^4.0.0",
|
||||||
|
"devicons-react": "^1.4.0",
|
||||||
"gitea-js": "^1.22.0",
|
"gitea-js": "^1.22.0",
|
||||||
|
"jotai": "^2.10.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"monaco-languageclient": "^8.8.3",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
"next-intl": "^3.26.1",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
|
"normalize-url": "^8.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"reconnecting-websocket": "^4.4.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vscode-ws-jsonrpc": "^3.3.2",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@shikijs/monaco": "^1.24.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "15.0.4"
|
"eslint-config-next": "15.0.4",
|
||||||
|
"postcss": "^8",
|
||||||
|
"shiki": "^1.24.2",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
public/gitea.svg
Normal file
18
public/gitea.svg
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
x="0px" y="0px" style="enable-background:new 0 0 640 640;" xml:space="preserve" viewBox="5.67 143.05 628.65 387.55">
|
||||||
|
<g>
|
||||||
|
<path id="teabag" style="fill:#FFFFFF"
|
||||||
|
d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8 c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4 c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z">
|
||||||
|
</path>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path style="fill:#609926"
|
||||||
|
d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2 c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5 c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5 c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3 c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1 C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4 c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7 S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55 c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8 l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z">
|
||||||
|
</path>
|
||||||
|
<path style="fill:#609926"
|
||||||
|
d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4 c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1 c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9 c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3 c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3 c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29 c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8 C343.2,346.5,335,363.3,326.8,380.1z">
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
43
src/actions/index.ts
Normal file
43
src/actions/index.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import api from "@/lib/gitea";
|
||||||
|
import { GitEntry, APIError } from "gitea-js";
|
||||||
|
|
||||||
|
export async function retrieveTreeStructure(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
sha: string
|
||||||
|
): Promise<GitEntry[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.repos.getTree(owner, repo, sha, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return response.data.tree || [];
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as APIError).message) {
|
||||||
|
console.error("Gitea API [getTree] Error", error);
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected Error", error);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to retrieve tree structure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retrieveFileContent(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
path: string
|
||||||
|
): Promise<{ encoding: string; content: string }> {
|
||||||
|
try {
|
||||||
|
const response = await api.repos.repoGetContents(owner, repo, path);
|
||||||
|
const { encoding, content } = response.data;
|
||||||
|
return { encoding: encoding || "", content: content || "" };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as APIError).message) {
|
||||||
|
console.error("Gitea API [repoGetContents] Error", error);
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected Error", error);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to retrieve file contents");
|
||||||
|
}
|
||||||
|
}
|
190
src/app/dashboard/components/dashboard-sidebar.tsx
Normal file
190
src/app/dashboard/components/dashboard-sidebar.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Bot,
|
||||||
|
Command,
|
||||||
|
Frame,
|
||||||
|
LifeBuoy,
|
||||||
|
Map,
|
||||||
|
PieChart,
|
||||||
|
Send,
|
||||||
|
Settings2,
|
||||||
|
SquareTerminal,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { NavMain } from "@/app/dashboard/components/nav-main";
|
||||||
|
import { NavUser } from "@/app/dashboard/components/nav-user";
|
||||||
|
import { NavProjects } from "@/app/dashboard/components/nav-projects";
|
||||||
|
import { NavSecondary } from "@/app/dashboard/components/nav-secondary";
|
||||||
|
|
||||||
|
export function DashboardSidebar({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
user: {
|
||||||
|
name: "gitea",
|
||||||
|
email: "about@gitea.com",
|
||||||
|
avatar: "/gitea.svg",
|
||||||
|
},
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "Playground",
|
||||||
|
url: "/playground",
|
||||||
|
icon: SquareTerminal,
|
||||||
|
isActive: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "History",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Starred",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Models",
|
||||||
|
url: "#",
|
||||||
|
icon: Bot,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Genesis",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Explorer",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Quantum",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Documentation",
|
||||||
|
url: "#",
|
||||||
|
icon: BookOpen,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Introduction",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Get Started",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tutorials",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Changelog",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "#",
|
||||||
|
icon: Settings2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "General",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Team",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Billing",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Limits",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navSecondary: [
|
||||||
|
{
|
||||||
|
title: t("Components.NavSecondary.support"),
|
||||||
|
url: siteConfig.url,
|
||||||
|
icon: LifeBuoy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Components.NavSecondary.feedback"),
|
||||||
|
url: siteConfig.url,
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "Design Engineering",
|
||||||
|
url: "#",
|
||||||
|
icon: Frame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sales & Marketing",
|
||||||
|
url: "#",
|
||||||
|
icon: PieChart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Travel",
|
||||||
|
url: "#",
|
||||||
|
icon: Map,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar variant="inset" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg" asChild>
|
||||||
|
<a href="#">
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<Command className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">Acme Inc</span>
|
||||||
|
<span className="truncate text-xs">Enterprise</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={data.navMain} />
|
||||||
|
<NavProjects projects={data.projects} />
|
||||||
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser user={data.user} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
77
src/app/dashboard/components/nav-main.tsx
Normal file
77
src/app/dashboard/components/nav-main.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function NavMain({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
isActive?: boolean;
|
||||||
|
items?: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
|
<a href={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
{item.items?.length ? (
|
||||||
|
<>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||||
|
<ChevronRight />
|
||||||
|
<span className="sr-only">Toggle</span>
|
||||||
|
</SidebarMenuAction>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.items?.map((subItem) => (
|
||||||
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
|
<SidebarMenuSubButton asChild>
|
||||||
|
<a href={subItem.url}>
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
90
src/app/dashboard/components/nav-projects.tsx
Normal file
90
src/app/dashboard/components/nav-projects.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
MoreHorizontal,
|
||||||
|
Share,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function NavProjects({
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}[];
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
const t = useTranslations("Components.NavProjects");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel>{t("projects")}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{projects.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.name}>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<a href={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuAction showOnHover>
|
||||||
|
<MoreHorizontal />
|
||||||
|
<span className="sr-only">{t("more")}</span>
|
||||||
|
</SidebarMenuAction>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-48"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align={isMobile ? "end" : "start"}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Folder className="text-muted-foreground" />
|
||||||
|
<span>{t("viewProject")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Share className="text-muted-foreground" />
|
||||||
|
<span>{t("shareProject")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Trash2 className="text-muted-foreground" />
|
||||||
|
<span>{t("deleteProject")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton>
|
||||||
|
<MoreHorizontal />
|
||||||
|
<span>{t("more")}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
39
src/app/dashboard/components/nav-secondary.tsx
Normal file
39
src/app/dashboard/components/nav-secondary.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function NavSecondary({
|
||||||
|
items,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}[];
|
||||||
|
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||||
|
return (
|
||||||
|
<SidebarGroup {...props}>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild size="sm">
|
||||||
|
<a href={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
159
src/app/dashboard/components/nav-user.tsx
Normal file
159
src/app/dashboard/components/nav-user.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
ChevronsUpDown,
|
||||||
|
CircleUserRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useSidebar,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import useConfirmationStore from "@/store/confirmationStore";
|
||||||
|
import { signIn, signOut, useSession } from "next-auth/react";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
const UserAvatar = ({ src, alt }: { src: string; alt: string }) => (
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage src={src} alt={alt} />
|
||||||
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const NavUser = ({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const t = useTranslations("Components.NavUser");
|
||||||
|
const { openConfirmation } = useConfirmationStore();
|
||||||
|
|
||||||
|
const currentUser = useMemo(() => {
|
||||||
|
if (session?.user) {
|
||||||
|
return {
|
||||||
|
name: session.user.name ?? "Unknown",
|
||||||
|
email: session.user.email ?? "Unknown",
|
||||||
|
avatar: session.user.image ?? "/gitea.svg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}, [session, user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<UserAvatar src={currentUser.avatar} alt={currentUser.name} />
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{currentUser.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs">{currentUser.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
{session?.user ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<UserAvatar
|
||||||
|
src={currentUser.avatar}
|
||||||
|
alt={currentUser.name}
|
||||||
|
/>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{currentUser.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs">
|
||||||
|
{currentUser.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<CircleUserRound />
|
||||||
|
{t("profile")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Bell />
|
||||||
|
{t("notifications")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Settings />
|
||||||
|
{t("settings")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
openConfirmation({
|
||||||
|
title: "Leaving Already?",
|
||||||
|
description: "Are you sure you want to say goodbye?",
|
||||||
|
cancelLabel: "Stay",
|
||||||
|
actionLabel: "Leave",
|
||||||
|
onCancel: () => {},
|
||||||
|
onAction: () => {
|
||||||
|
signOut();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut />
|
||||||
|
{t("logout")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={() => signIn()}>
|
||||||
|
<LogIn />
|
||||||
|
{t("login")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
);
|
||||||
|
};
|
52
src/app/dashboard/layout.tsx
Normal file
52
src/app/dashboard/layout.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ModeSwitcher } from "@/components/mode-switcher";
|
||||||
|
import ConfirmationDialog from "@/dialogs/ConfirmationDialog";
|
||||||
|
import LanguageSwitcher from "@/components/language-switcher";
|
||||||
|
import { DashboardSidebar } from "@/app/dashboard/components/dashboard-sidebar";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<DashboardSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||||
|
<div className="flex flex-1 items-center gap-2 px-3">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<ModeSwitcher />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage className="line-clamp-1">
|
||||||
|
Project Management & Task Tracking
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto px-3">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
<ConfirmationDialog />
|
||||||
|
</SidebarProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
17
src/app/dashboard/page.tsx
Normal file
17
src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const t = useTranslations("Page.DashboardPage");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||||
|
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||||
|
<div className="aspect-video rounded-xl bg-muted/50 flex items-center justify-center">
|
||||||
|
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||||
|
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -27,7 +27,15 @@
|
|||||||
--chart-3: 197 37% 24%;
|
--chart-3: 197 37% 24%;
|
||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem
|
--radius: 0.5rem;
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
@ -53,7 +61,15 @@
|
|||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%
|
--chart-5: 340 75% 55%;
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer base {
|
@layer base {
|
||||||
|
@ -2,6 +2,9 @@ import "@/app/globals.css";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
@ -11,17 +14,30 @@ export const metadata: Metadata = {
|
|||||||
"A full-stack, open-source online judge platform designed to elevate college programming education",
|
"A full-stack, open-source online judge platform designed to elevate college programming education",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full" suppressHydrationWarning>
|
<html lang={locale} className="h-full" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={cn("font-sans antialiased flex min-h-full", inter.variable)}
|
className={cn("font-sans antialiased flex min-h-full", inter.variable)}
|
||||||
>
|
>
|
||||||
<main className="w-full">{children}</main>
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
enableColorScheme
|
||||||
|
>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<div className="w-full">{children}</div>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
172
src/app/playground/components/code-editor.tsx
Normal file
172
src/app/playground/components/code-editor.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
toSocket,
|
||||||
|
WebSocketMessageReader,
|
||||||
|
WebSocketMessageWriter,
|
||||||
|
} from "vscode-ws-jsonrpc";
|
||||||
|
import {
|
||||||
|
CloseAction,
|
||||||
|
ErrorAction,
|
||||||
|
MessageTransports,
|
||||||
|
} from "vscode-languageclient";
|
||||||
|
import "@fontsource-variable/fira-code";
|
||||||
|
import normalizeUrl from "normalize-url";
|
||||||
|
import { createHighlighter } from "shiki";
|
||||||
|
import type { editor } from "monaco-editor";
|
||||||
|
import { shikiToMonaco } from "@shikijs/monaco";
|
||||||
|
import { useEffect, useRef, useMemo } from "react";
|
||||||
|
import { useCodeEditorStore } from "@/store/codeEditorStore";
|
||||||
|
import { MonacoLanguageClient } from "monaco-languageclient";
|
||||||
|
import MonacoEditor, { type Monaco } from "@monaco-editor/react";
|
||||||
|
import { initServices } from "monaco-languageclient/vscode/services";
|
||||||
|
|
||||||
|
const ReconnectingWebSocket = require("reconnecting-websocket");
|
||||||
|
|
||||||
|
const ADDITIONAL_THEMES = [
|
||||||
|
"andromeeda",
|
||||||
|
"aurora-x",
|
||||||
|
"ayu-dark",
|
||||||
|
"catppuccin-frappe",
|
||||||
|
"catppuccin-latte",
|
||||||
|
"catppuccin-macchiato",
|
||||||
|
"catppuccin-mocha",
|
||||||
|
"dark-plus",
|
||||||
|
"dracula",
|
||||||
|
"dracula-soft",
|
||||||
|
"one-dark-pro",
|
||||||
|
"vitesse-dark",
|
||||||
|
"vitesse-light",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADDITIONAL_LANGUAGES = [
|
||||||
|
"c",
|
||||||
|
"cpp",
|
||||||
|
"java",
|
||||||
|
"python",
|
||||||
|
"typescript",
|
||||||
|
] as const satisfies Parameters<typeof createHighlighter>[0]["langs"];
|
||||||
|
|
||||||
|
export function CodeEditor() {
|
||||||
|
const monacoRef = useRef<Monaco | null>(null);
|
||||||
|
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
const { lang, path, theme, value, isMinimap, isLigature } =
|
||||||
|
useCodeEditorStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (monacoRef.current && editorRef.current) {
|
||||||
|
monacoRef.current.editor.setTheme(theme);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
minimap: { enabled: isMinimap, size: "fit" },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "Fira Code Variable, monospace",
|
||||||
|
tabSize: 4,
|
||||||
|
showFoldingControls: "always",
|
||||||
|
fontLigatures: isLigature,
|
||||||
|
automaticLayout: true,
|
||||||
|
autoIndent: "full",
|
||||||
|
guides: {
|
||||||
|
bracketPairs: true,
|
||||||
|
indentation: true,
|
||||||
|
},
|
||||||
|
} as editor.IStandaloneEditorConstructionOptions),
|
||||||
|
[isMinimap, isLigature]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLanguageClient = (
|
||||||
|
transports: MessageTransports
|
||||||
|
): MonacoLanguageClient => {
|
||||||
|
return new MonacoLanguageClient({
|
||||||
|
name: "LSP Language Client",
|
||||||
|
clientOptions: {
|
||||||
|
documentSelector: ADDITIONAL_LANGUAGES,
|
||||||
|
errorHandler: {
|
||||||
|
error: () => ({ action: ErrorAction.Continue }),
|
||||||
|
closed: () => ({ action: CloseAction.DoNotRestart }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connectionProvider: {
|
||||||
|
get: () => {
|
||||||
|
return Promise.resolve(transports);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!monacoRef.current) return;
|
||||||
|
const url = normalizeUrl(`ws://172.20.0.13:3000`);
|
||||||
|
const socketOptions = {
|
||||||
|
maxReconnectionDelay: 10000,
|
||||||
|
minReconnectionDelay: 1000,
|
||||||
|
reconnectionDelayGrowFactor: 1.3,
|
||||||
|
connectionTimeout: 10000,
|
||||||
|
maxRetries: Infinity,
|
||||||
|
debug: true,
|
||||||
|
};
|
||||||
|
const webSocket: WebSocket = new ReconnectingWebSocket.default(
|
||||||
|
url,
|
||||||
|
[],
|
||||||
|
socketOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
webSocket.onopen = () => {
|
||||||
|
console.log("WebSocket connection opened");
|
||||||
|
const socket = toSocket(webSocket);
|
||||||
|
const reader = new WebSocketMessageReader(socket);
|
||||||
|
const writer = new WebSocketMessageWriter(socket);
|
||||||
|
const languageClient = createLanguageClient({ reader, writer });
|
||||||
|
languageClient.start();
|
||||||
|
reader.onClose(() => languageClient.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocket.onerror = (error) => {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocket.onclose = (event) => {
|
||||||
|
console.log("WebSocket connection closed:", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
webSocket.close();
|
||||||
|
};
|
||||||
|
}, [monacoRef]);
|
||||||
|
|
||||||
|
const handleEditorMount = async (
|
||||||
|
editor: editor.IStandaloneCodeEditor,
|
||||||
|
monaco: Monaco
|
||||||
|
) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
monacoRef.current = monaco;
|
||||||
|
|
||||||
|
for (const lang of ADDITIONAL_LANGUAGES) {
|
||||||
|
monacoRef.current?.languages.register({ id: lang });
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlighter = await createHighlighter({
|
||||||
|
themes: ADDITIONAL_THEMES,
|
||||||
|
langs: ADDITIONAL_LANGUAGES,
|
||||||
|
});
|
||||||
|
|
||||||
|
shikiToMonaco(highlighter, monacoRef.current);
|
||||||
|
|
||||||
|
await initServices({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonacoEditor
|
||||||
|
defaultLanguage={lang}
|
||||||
|
theme={theme}
|
||||||
|
path={path}
|
||||||
|
options={options}
|
||||||
|
defaultValue={value}
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
18
src/app/playground/components/ligatures-checkbox.tsx
Normal file
18
src/app/playground/components/ligatures-checkbox.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useCodeEditorStore } from "@/store/codeEditorStore";
|
||||||
|
|
||||||
|
export default function LigaturesCheckbox() {
|
||||||
|
const t = useTranslations("Page.PlaygroundPage.Components.ligaturesCheckbox");
|
||||||
|
const { setIsLigature } = useCodeEditorStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox onCheckedChange={setIsLigature} />
|
||||||
|
<Label>{t("label")}</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
18
src/app/playground/components/minimap-checkbox.tsx
Normal file
18
src/app/playground/components/minimap-checkbox.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useCodeEditorStore } from "@/store/codeEditorStore";
|
||||||
|
|
||||||
|
export default function MinimapCheckbox() {
|
||||||
|
const t = useTranslations("Page.PlaygroundPage.Components.minimapCheckbox");
|
||||||
|
const { setIsMinimap } = useCodeEditorStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox onCheckedChange={setIsMinimap} />
|
||||||
|
<Label>{t("label")}</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
223
src/app/playground/components/playground-sidebar.tsx
Normal file
223
src/app/playground/components/playground-sidebar.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
COriginal,
|
||||||
|
CplusplusOriginal,
|
||||||
|
JavaOriginal,
|
||||||
|
PythonOriginal,
|
||||||
|
TypescriptOriginal,
|
||||||
|
} from "devicons-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarRail,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { GitEntry } from "gitea-js";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import * as actions from "@/actions";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useCodeEditorStore } from "@/store/codeEditorStore";
|
||||||
|
import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
|
||||||
|
|
||||||
|
interface FileTree {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "blob" | "tree";
|
||||||
|
children?: { [key: string]: FileTree };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTree(tree: GitEntry[]): FileTree {
|
||||||
|
const root: FileTree = { name: "", type: "tree", path: "", children: {} };
|
||||||
|
|
||||||
|
tree.forEach((entry: GitEntry) => {
|
||||||
|
if (!entry.path) return;
|
||||||
|
|
||||||
|
const pathParts = entry.path.split("/");
|
||||||
|
let currentLevel = root;
|
||||||
|
|
||||||
|
pathParts.forEach((part: string, index: number) => {
|
||||||
|
if (!currentLevel.children) {
|
||||||
|
currentLevel.children = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = pathParts.slice(0, index + 1).join("/");
|
||||||
|
|
||||||
|
if (!currentLevel.children[part]) {
|
||||||
|
currentLevel.children[part] = {
|
||||||
|
name: part,
|
||||||
|
path: fullPath,
|
||||||
|
type: entry.type as "blob" | "tree",
|
||||||
|
children: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel = currentLevel.children[part];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (root.children) {
|
||||||
|
const sortedChildren = Object.values(root.children).sort((a, b) => {
|
||||||
|
if (a.type === "tree" && b.type === "blob") return -1;
|
||||||
|
if (a.type === "blob" && b.type === "tree") return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
root.children = sortedChildren.reduce((acc, child) => {
|
||||||
|
acc[child.name] = child;
|
||||||
|
return acc;
|
||||||
|
}, {} as { [key: string]: FileTree });
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundSidebar({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [fileTree, setFileTree] = React.useState<FileTree>({
|
||||||
|
name: "",
|
||||||
|
path: "",
|
||||||
|
type: "tree",
|
||||||
|
children: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const username = session?.user?.name ?? "";
|
||||||
|
const t = useTranslations("Page.PlaygroundPage.Components.playgroundSidebar");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function fetchFileTree() {
|
||||||
|
if (username) {
|
||||||
|
const tree: GitEntry[] = await actions.retrieveTreeStructure(
|
||||||
|
username,
|
||||||
|
"playground",
|
||||||
|
"main"
|
||||||
|
);
|
||||||
|
const newFileTree = buildFileTree(tree);
|
||||||
|
setFileTree(newFileTree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchFileTree();
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar {...props}>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>{t("title")}</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{fileTree.children &&
|
||||||
|
Object.values(fileTree.children).map((item, index) => (
|
||||||
|
<Tree key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tree({ item }: { item: FileTree }) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const username = session?.user?.name ?? "";
|
||||||
|
const { name, path, type, children } = item;
|
||||||
|
const fileExtension = name.split(".").pop();
|
||||||
|
|
||||||
|
let fileIcon = <File />;
|
||||||
|
|
||||||
|
if (fileExtension === "c") {
|
||||||
|
fileIcon = <COriginal />;
|
||||||
|
} else if (fileExtension === "cpp") {
|
||||||
|
fileIcon = <CplusplusOriginal />;
|
||||||
|
} else if (fileExtension === "java") {
|
||||||
|
fileIcon = <JavaOriginal />;
|
||||||
|
} else if (fileExtension === "py") {
|
||||||
|
fileIcon = <PythonOriginal />;
|
||||||
|
} else if (fileExtension === "ts") {
|
||||||
|
fileIcon = <TypescriptOriginal />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const { setLang, setPath, setValue } = useCodeEditorStore();
|
||||||
|
|
||||||
|
const langMap: { [key: string]: string } = {
|
||||||
|
c: "c",
|
||||||
|
cpp: "cpp",
|
||||||
|
java: "java",
|
||||||
|
py: "python",
|
||||||
|
ts: "typescript",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileClick = async () => {
|
||||||
|
if (type === "blob") {
|
||||||
|
const fileContentsResponse = await actions.retrieveFileContent(
|
||||||
|
username,
|
||||||
|
"playground",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
const { encoding, content } = fileContentsResponse;
|
||||||
|
const decodedContent = Buffer.from(
|
||||||
|
content,
|
||||||
|
encoding as BufferEncoding
|
||||||
|
).toString("utf-8");
|
||||||
|
const lang = langMap[fileExtension ?? ""] || "plaintext";
|
||||||
|
setLang(lang);
|
||||||
|
setPath(path);
|
||||||
|
setValue(decodedContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "blob") {
|
||||||
|
return (
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="data-[active=true]:bg-transparent"
|
||||||
|
onClick={handleFileClick}
|
||||||
|
>
|
||||||
|
{fileIcon}
|
||||||
|
{name}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<Collapsible
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton>
|
||||||
|
<ChevronRight className="transition-transform" />
|
||||||
|
{isOpen ? <FolderOpen /> : <Folder />}
|
||||||
|
{name}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{children &&
|
||||||
|
Object.values(children).map((subItem, index) => (
|
||||||
|
<Tree key={index} item={subItem} />
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
47
src/app/playground/components/theme-selector.tsx
Normal file
47
src/app/playground/components/theme-selector.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useCodeEditorStore } from "@/store/codeEditorStore";
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: "andromeeda", label: "Andromeeda" },
|
||||||
|
{ value: "aurora-x", label: "Aurora X" },
|
||||||
|
{ value: "ayu-dark", label: "Ayu Dark" },
|
||||||
|
{ value: "catppuccin-frappe", label: "Catppuccin Frappé" },
|
||||||
|
{ value: "catppuccin-latte", label: "Catppuccin Latte" },
|
||||||
|
{ value: "catppuccin-macchiato", label: "Catppuccin Macchiato" },
|
||||||
|
{ value: "catppuccin-mocha", label: "Catppuccin Mocha" },
|
||||||
|
{ value: "dark-plus", label: "Dark Plus" },
|
||||||
|
{ value: "dracula", label: "Dracula Theme" },
|
||||||
|
{ value: "dracula-soft", label: "Dracula Theme Soft" },
|
||||||
|
{ value: "one-dark-pro", label: "One Dark Pro" },
|
||||||
|
{ value: "vitesse-dark", label: "Vitesse Dark" },
|
||||||
|
{ value: "vitesse-light", label: "Vitesse Light" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ThemeSelector() {
|
||||||
|
const { theme, setTheme } = useCodeEditorStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select defaultValue={theme} onValueChange={setTheme}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{themeOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
41
src/app/playground/layout.tsx
Normal file
41
src/app/playground/layout.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import ThemeSelector from "./components/theme-selector";
|
||||||
|
import { ModeSwitcher } from "@/components/mode-switcher";
|
||||||
|
import LanguageSwitcher from "@/components/language-switcher";
|
||||||
|
import LigaturesCheckbox from "./components/ligatures-checkbox";
|
||||||
|
import { PlaygroundSidebar } from "@/app/playground/components/playground-sidebar";
|
||||||
|
import MinimapCheckbox from "./components/minimap-checkbox";
|
||||||
|
|
||||||
|
export default function PlaygroundLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<PlaygroundSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||||
|
<div className="flex flex-1 items-center gap-2 px-3">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<ModeSwitcher />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
<ThemeSelector />
|
||||||
|
<LigaturesCheckbox />
|
||||||
|
<MinimapCheckbox />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto px-3">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
9
src/app/playground/page.tsx
Normal file
9
src/app/playground/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { CodeEditor } from "./components/code-editor";
|
||||||
|
|
||||||
|
export default function PlaygroundPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<CodeEditor />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -12,4 +12,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.AUTH_SECRET,
|
secret: process.env.AUTH_SECRET,
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
});
|
});
|
||||||
|
44
src/components/language-switcher.tsx
Normal file
44
src/components/language-switcher.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { Locale } from "@/i18n/config";
|
||||||
|
import { setUserLocale } from "@/services/locale";
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ value: "en-US", label: "English (United States)", flag: "🇺🇸" },
|
||||||
|
{ value: "zh-CN", label: "简体中文", flag: "🇨🇳" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LanguageSwitcher() {
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
function onChange(value: string) {
|
||||||
|
const locale = value as Locale;
|
||||||
|
setUserLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select defaultValue={locale} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="[&>span]:flex [&>span]:items-center [&>span]:gap-2 [&>span_svg]:shrink-0 [&>span_svg]:text-muted-foreground/80">
|
||||||
|
<SelectValue placeholder="Select language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="[&_*[role=option]>span>svg]:shrink-0 [&_*[role=option]>span>svg]:text-muted-foreground/80 [&_*[role=option]>span]:end-2 [&_*[role=option]>span]:start-auto [&_*[role=option]>span]:flex [&_*[role=option]>span]:items-center [&_*[role=option]>span]:gap-2 [&_*[role=option]]:pe-8 [&_*[role=option]]:ps-2">
|
||||||
|
{languages.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
<span className="text-lg leading-none">{item.flag}</span>{" "}
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
37
src/components/mode-switcher.tsx
Normal file
37
src/components/mode-switcher.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// This implementation is derived from the solution provided by [shadcn] in shadcn/ui
|
||||||
|
// https://github.com/shadcn-ui/ui
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { META_THEME_COLORS } from "@/config/site";
|
||||||
|
import { useMetaColor } from "@/hooks/use-meta-color";
|
||||||
|
|
||||||
|
export function ModeSwitcher() {
|
||||||
|
const { setMetaColor } = useMetaColor();
|
||||||
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const toggleTheme = React.useCallback(() => {
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||||
|
setMetaColor(
|
||||||
|
resolvedTheme === "dark"
|
||||||
|
? META_THEME_COLORS.light
|
||||||
|
: META_THEME_COLORS.dark
|
||||||
|
);
|
||||||
|
}, [resolvedTheme, setTheme, setMetaColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="group/toggle h-8 w-8 px-0"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
<Sun className="hidden [html.dark_&]:block" />
|
||||||
|
<Moon className="hidden [html.light_&]:block" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
22
src/components/theme-provider.tsx
Normal file
22
src/components/theme-provider.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// This implementation is derived from the solution provided by [shadcn] in shadcn/ui
|
||||||
|
// https://github.com/shadcn-ui/ui
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return (
|
||||||
|
<JotaiProvider>
|
||||||
|
<NextThemesProvider {...props}>
|
||||||
|
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
|
||||||
|
</NextThemesProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
);
|
||||||
|
}
|
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
50
src/components/ui/avatar.tsx
Normal file
50
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||||
|
Breadcrumb.displayName = "Breadcrumb"
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<"ol">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList"
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<"span">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
11
src/components/ui/collapsible.tsx
Normal file
11
src/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
201
src/components/ui/dropdown-menu.tsx
Normal file
201
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
159
src/components/ui/select.tsx
Normal file
159
src/components/ui/select.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
140
src/components/ui/sheet.tsx
Normal file
140
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
{children}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = "SheetHeader"
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
763
src/components/ui/sidebar.tsx
Normal file
763
src/components/ui/sidebar.tsx
Normal file
@ -0,0 +1,763 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { VariantProps, cva } from "class-variance-authority"
|
||||||
|
import { PanelLeft } from "lucide-react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContext = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContext | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarProvider = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile
|
||||||
|
? setOpenMobile((open) => !open)
|
||||||
|
: setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContext>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SidebarProvider.displayName = "SidebarProvider"
|
||||||
|
|
||||||
|
const Sidebar = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="group peer hidden md:block text-sidebar-foreground"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Sidebar.displayName = "Sidebar"
|
||||||
|
|
||||||
|
const SidebarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Button>,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, onClick, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("h-7 w-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarTrigger.displayName = "SidebarTrigger"
|
||||||
|
|
||||||
|
const SidebarRail = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||||
|
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarRail.displayName = "SidebarRail"
|
||||||
|
|
||||||
|
const SidebarInset = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"main">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||||
|
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInset.displayName = "SidebarInset"
|
||||||
|
|
||||||
|
const SidebarInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Input>,
|
||||||
|
React.ComponentProps<typeof Input>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInput.displayName = "SidebarInput"
|
||||||
|
|
||||||
|
const SidebarHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarHeader.displayName = "SidebarHeader"
|
||||||
|
|
||||||
|
const SidebarFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarFooter.displayName = "SidebarFooter"
|
||||||
|
|
||||||
|
const SidebarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Separator>,
|
||||||
|
React.ComponentProps<typeof Separator>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarSeparator.displayName = "SidebarSeparator"
|
||||||
|
|
||||||
|
const SidebarContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarContent.displayName = "SidebarContent"
|
||||||
|
|
||||||
|
const SidebarGroup = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroup.displayName = "SidebarGroup"
|
||||||
|
|
||||||
|
const SidebarGroupLabel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||||
|
|
||||||
|
const SidebarGroupAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||||
|
|
||||||
|
const SidebarGroupContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||||
|
|
||||||
|
const SidebarMenu = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenu.displayName = "SidebarMenu"
|
||||||
|
|
||||||
|
const SidebarMenuItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const SidebarMenuButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||||
|
|
||||||
|
const SidebarMenuAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||||
|
|
||||||
|
const SidebarMenuBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||||
|
|
||||||
|
const SidebarMenuSkeleton = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, showIcon = false, ...props }, ref) => {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||||
|
|
||||||
|
const SidebarMenuSub = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||||
|
|
||||||
|
const SidebarMenuSubItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||||
|
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||||
|
|
||||||
|
const SidebarMenuSubButton = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
55
src/components/ui/tabs.tsx
Normal file
55
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
32
src/components/ui/tooltip.tsx
Normal file
32
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
8
src/config/site.ts
Normal file
8
src/config/site.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const siteConfig = {
|
||||||
|
url: "https://github.com/NGC2207/judge4c",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const META_THEME_COLORS = {
|
||||||
|
light: "#ffffff",
|
||||||
|
dark: "#09090b",
|
||||||
|
};
|
51
src/dialogs/ConfirmationDialog.tsx
Normal file
51
src/dialogs/ConfirmationDialog.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// This implementation is derived from the solution provided by [baydisng13] in the discussion thread:
|
||||||
|
// https://github.com/shadcn-ui/ui/discussions/3875
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import useConfirmationStore from "@/store/confirmationStore";
|
||||||
|
|
||||||
|
const ConfirmationDialog = () => {
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cancelLabel,
|
||||||
|
actionLabel,
|
||||||
|
onCancel,
|
||||||
|
onAction,
|
||||||
|
closeConfirmation,
|
||||||
|
} = useConfirmationStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={closeConfirmation}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmationDialog;
|
27
src/hooks/use-meta-color.ts
Normal file
27
src/hooks/use-meta-color.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// This implementation is derived from the solution provided by [shadcn] in shadcn/ui
|
||||||
|
// https://github.com/shadcn-ui/ui
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { META_THEME_COLORS } from "@/config/site";
|
||||||
|
|
||||||
|
export function useMetaColor() {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const metaColor = React.useMemo(() => {
|
||||||
|
return resolvedTheme !== "dark"
|
||||||
|
? META_THEME_COLORS.light
|
||||||
|
: META_THEME_COLORS.dark;
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
const setMetaColor = React.useCallback((color: string) => {
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute("content", color);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metaColor,
|
||||||
|
setMetaColor,
|
||||||
|
};
|
||||||
|
}
|
21
src/hooks/use-mobile.tsx
Normal file
21
src/hooks/use-mobile.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !!isMobile;
|
||||||
|
}
|
5
src/i18n/config.ts
Normal file
5
src/i18n/config.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
|
export const locales = ["en-US", "zh-CN"] as const;
|
||||||
|
|
||||||
|
export const defaultLocale: Locale = "en-US";
|
11
src/i18n/request.ts
Normal file
11
src/i18n/request.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { getUserLocale } from "@/services/locale";
|
||||||
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
|
||||||
|
export default getRequestConfig(async () => {
|
||||||
|
const locale = await getUserLocale();
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
13
src/lib/gitea.ts
Normal file
13
src/lib/gitea.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import fetch from "cross-fetch";
|
||||||
|
import { giteaApi } from "gitea-js";
|
||||||
|
|
||||||
|
if (!process.env.GITEA_URL) {
|
||||||
|
throw new Error("GITEA_URL environment variable is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = giteaApi(process.env.GITEA_URL, {
|
||||||
|
token: process.env.GITEA_TOKEN,
|
||||||
|
customFetch: fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
@ -2,7 +2,7 @@ import { Email as GiteaEmail, User } from "gitea-js";
|
|||||||
import { OAuthConfig, OAuthUserConfig } from "next-auth/providers";
|
import { OAuthConfig, OAuthUserConfig } from "next-auth/providers";
|
||||||
|
|
||||||
export interface GiteaProfile extends User {
|
export interface GiteaProfile extends User {
|
||||||
username: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Gitea(
|
export default function Gitea(
|
||||||
@ -65,8 +65,13 @@ export default function Gitea(
|
|||||||
name: profile.username ?? profile.login,
|
name: profile.username ?? profile.login,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
image: profile.avatar_url,
|
image: profile.avatar_url,
|
||||||
|
language: profile.language,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
options: config,
|
options: config,
|
||||||
|
style: {
|
||||||
|
logo: "/gitea.svg",
|
||||||
|
brandColor: "#609926",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
14
src/services/locale.ts
Normal file
14
src/services/locale.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { defaultLocale, Locale } from "@/i18n/config";
|
||||||
|
|
||||||
|
const LOBE_LOCALE_COOKIE = "JUDGE4C_LOCALE";
|
||||||
|
|
||||||
|
export async function getUserLocale() {
|
||||||
|
return (await cookies()).get(LOBE_LOCALE_COOKIE)?.value || defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserLocale(locale: Locale) {
|
||||||
|
(await cookies()).set(LOBE_LOCALE_COOKIE, locale);
|
||||||
|
}
|
31
src/store/codeEditorStore.ts
Normal file
31
src/store/codeEditorStore.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface CodeEditorState {
|
||||||
|
lang: string;
|
||||||
|
path: string;
|
||||||
|
theme: string;
|
||||||
|
value: string;
|
||||||
|
isMinimap: boolean;
|
||||||
|
isLigature: boolean;
|
||||||
|
setLang: (lang: string) => void;
|
||||||
|
setPath: (path: string) => void;
|
||||||
|
setTheme: (theme: string) => void;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
setIsMinimap: (isMinimap: boolean) => void;
|
||||||
|
setIsLigature: (isLigature: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCodeEditorStore = create<CodeEditorState>((set) => ({
|
||||||
|
lang: "c",
|
||||||
|
path: "",
|
||||||
|
theme: "one-dark-pro",
|
||||||
|
value: "",
|
||||||
|
isMinimap: false,
|
||||||
|
isLigature: false,
|
||||||
|
setLang: (lang) => set({ lang }),
|
||||||
|
setPath: (path) => set({ path }),
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
setValue: (value) => set({ value }),
|
||||||
|
setIsMinimap: (isMinimap) => set({ isMinimap }),
|
||||||
|
setIsLigature: (isLigature) => set({ isLigature }),
|
||||||
|
}));
|
71
src/store/confirmationStore.ts
Normal file
71
src/store/confirmationStore.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// This implementation is derived from the solution provided by [baydisng13] in the discussion thread:
|
||||||
|
// https://github.com/shadcn-ui/ui/discussions/3875
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface ConfirmationState {
|
||||||
|
open: boolean;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
cancelLabel: string | null;
|
||||||
|
actionLabel: string | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAction: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmationActions {
|
||||||
|
openConfirmation: (data: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAction: () => void;
|
||||||
|
}) => void;
|
||||||
|
closeConfirmation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useConfirmationStore = create<ConfirmationState & ConfirmationActions>(
|
||||||
|
(set) => ({
|
||||||
|
open: false,
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
cancelLabel: null,
|
||||||
|
actionLabel: null,
|
||||||
|
onCancel: () => {},
|
||||||
|
onAction: () => {},
|
||||||
|
openConfirmation: (data) =>
|
||||||
|
set(() => ({
|
||||||
|
open: true,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
cancelLabel: data.cancelLabel,
|
||||||
|
actionLabel: data.actionLabel,
|
||||||
|
onCancel: data.onCancel,
|
||||||
|
onAction: data.onAction,
|
||||||
|
})),
|
||||||
|
closeConfirmation: () => {
|
||||||
|
// Close the confirmation dialog by setting the 'open' state to false.
|
||||||
|
set((state) => ({
|
||||||
|
...state,
|
||||||
|
open: false,
|
||||||
|
}));
|
||||||
|
// After a short delay (100ms), reset the dialog's state to its initial values.
|
||||||
|
// This delay ensures that the dialog is fully closed before resetting,
|
||||||
|
// preventing any visual flickering or UI inconsistencies.
|
||||||
|
setTimeout(() => {
|
||||||
|
set((state) => ({
|
||||||
|
...state,
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
cancelLabel: null,
|
||||||
|
actionLabel: null,
|
||||||
|
onCancel: () => {},
|
||||||
|
onAction: () => {},
|
||||||
|
}));
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useConfirmationStore;
|
@ -53,6 +53,16 @@ export default {
|
|||||||
"4": "hsl(var(--chart-4))",
|
"4": "hsl(var(--chart-4))",
|
||||||
"5": "hsl(var(--chart-5))",
|
"5": "hsl(var(--chart-5))",
|
||||||
},
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-background))",
|
||||||
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
primary: "hsl(var(--sidebar-primary))",
|
||||||
|
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||||
|
accent: "hsl(var(--sidebar-accent))",
|
||||||
|
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||||
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
Loading…
Reference in New Issue
Block a user