mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
[Update] Update various configuration files, ESLint config, Prettier config, and added new files including a CHANGELOG.md.
This commit is contained in:
+1
-3
@@ -3,7 +3,5 @@ GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file
|
||||
ENCRYPTION_KEY=
|
||||
SITE_PASSWORD=
|
||||
|
||||
# Only fill with the domain name, without the protocol and trailing slash
|
||||
# This will be used, and will fallback to VERCEL_URL if it's not available
|
||||
# Example: https://drive-demo.mbaharip.com -> drive-demo.mbaharip.com
|
||||
# [Optional] Only domain, without protocol (ex: mbaharip.com)
|
||||
NEXT_PUBLIC_DOMAIN=
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
checksVoidReturn: {
|
||||
attributes: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
ignorePatterns: ["**/*/_*"],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"]
|
||||
}
|
||||
@@ -35,6 +35,7 @@ next-env.d.ts
|
||||
/docs
|
||||
/src/pages/api/legacy/
|
||||
/**/*/_legacy/
|
||||
/**/*/_*/
|
||||
/data
|
||||
/.*/
|
||||
/src/_app
|
||||
|
||||
@@ -25,9 +25,9 @@ module.exports = {
|
||||
importOrder: [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^~/(app|components)/(.*)$",
|
||||
"^~/(utils|hooks|context)/(.*)$",
|
||||
"^~/(utils|hooks|context|lib)/(.*)$",
|
||||
"^~/types/(.*)$",
|
||||
"^~/(config|styles)/(.*)$",
|
||||
"^~/(config|styles|actions|constant)/(.*)$",
|
||||
"^(config|actions)$",
|
||||
"^[./]",
|
||||
],
|
||||
+8
-4
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/utils/cn"
|
||||
}
|
||||
"utils": "~/lib/utils",
|
||||
"ui": "~/components/ui",
|
||||
"lib": "~/lib",
|
||||
"hooks": "~/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
+3
-13
@@ -1,3 +1,6 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
webpack: (config) => {
|
||||
@@ -5,19 +8,6 @@ const nextConfig = {
|
||||
|
||||
return config;
|
||||
},
|
||||
// async headers() {
|
||||
// return [
|
||||
// {
|
||||
// source: "/:path*",
|
||||
// headers: [
|
||||
// {
|
||||
// key: "Cache-Control",
|
||||
// value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ];
|
||||
// },
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
+46
-48
@@ -1,73 +1,69 @@
|
||||
{
|
||||
"name": "next-gdrive-index",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"dev:turbo": "next dev -p 3000 --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"cypress": "cypress open",
|
||||
"pretty": "prettier --write ."
|
||||
"lint:fix": "next lint --fix",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json}\" --cache",
|
||||
"cypress": "cypress open"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cyntler/react-doc-viewer": "^1.13.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-context-menu": "^2.2.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.0.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"axios": "^1.3.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.0.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"fflate": "^0.8.2",
|
||||
"framer-motion": "^11.13.3",
|
||||
"googleapis": "^118.0.0",
|
||||
"input-otp": "^1.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "^14.1.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.1.2",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.11",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-h5-audio-player": "^3.9.1",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-is": "^18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-player": "^2.13.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-resizable-panels": "^2.0.16",
|
||||
"react-toastify": "^9.1.3",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"rehype-prism-plus": "^1.6.3",
|
||||
"rehype-raw": "6.1.1",
|
||||
@@ -76,33 +72,35 @@
|
||||
"remark-math": "^5.1.1",
|
||||
"remark-slug": "^7.0.1",
|
||||
"remark-toc": "^8.0.1",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-debouncy": "^5.0.1",
|
||||
"vaul": "^0.9.0",
|
||||
"zod": "^3.22.4"
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^20",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.150.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"cypress": "^13.16.1",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "8.38.0",
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.0.0",
|
||||
"prettier-plugin-tailwindcss": "0.5.12",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,351 @@
|
||||
"use server";
|
||||
|
||||
import { type z } from "zod";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import { Schema_File, Schema_File_Shortcut } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* List files in a folder
|
||||
* @param {object} options
|
||||
* @param {string} options.id (optional) - Folder ID to fetch, default is root folder
|
||||
* @param {string} options.pageToken (optional) - Page token to fetch next page
|
||||
*/
|
||||
export async function ListFiles({ id, pageToken }: { id?: string; pageToken?: string } = {}): Promise<
|
||||
ActionResponseSchema<{
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string | null;
|
||||
}>
|
||||
> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and ");
|
||||
const filterQuery: string = [...config.apiConfig.defaultQuery, `'${decryptedId}' in parents`, filterName].join(
|
||||
" and ",
|
||||
);
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No files found",
|
||||
data: {
|
||||
files: [],
|
||||
nextPageToken: data.nextPageToken,
|
||||
},
|
||||
};
|
||||
|
||||
const files: z.infer<typeof Schema_File>[] = [];
|
||||
for (const file of data.files) {
|
||||
files.push({
|
||||
encryptedId: await encryptionService.encrypt(file.id!),
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined,
|
||||
name: file.name!,
|
||||
mimeType: file.mimeType!,
|
||||
trashed: file.trashed ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension ?? undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink ?? undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = Schema_File.array().safeParse(files);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse files",
|
||||
error: parsed.error.message,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Files found",
|
||||
data: {
|
||||
files: parsed.data,
|
||||
nextPageToken: data.nextPageToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file details
|
||||
* @param id - File ID to fetch
|
||||
*/
|
||||
export async function GetFile(id: string): Promise<ActionResponseSchema<z.infer<typeof Schema_File> | null>> {
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: config.apiConfig.defaultField,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.id)
|
||||
return {
|
||||
success: false,
|
||||
message: "File not found",
|
||||
error: "NotFound",
|
||||
};
|
||||
|
||||
const file: z.infer<typeof Schema_File> = {
|
||||
encryptedId: await encryptionService.encrypt(data.id),
|
||||
encryptedWebContentLink: data.webContentLink ? await encryptionService.encrypt(data.webContentLink) : undefined,
|
||||
name: data.name!,
|
||||
mimeType: data.mimeType!,
|
||||
trashed: data.trashed ?? false,
|
||||
modifiedTime: new Date(data.modifiedTime!).toLocaleDateString(),
|
||||
fileExtension: data.fileExtension ?? undefined,
|
||||
size: data.size ? Number(data.size) : undefined,
|
||||
thumbnailLink: data.thumbnailLink ?? undefined,
|
||||
imageMediaMetadata: data.imageMediaMetadata
|
||||
? {
|
||||
width: Number(data.imageMediaMetadata.width),
|
||||
height: Number(data.imageMediaMetadata.height),
|
||||
rotation: Number(data.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: data.videoMediaMetadata
|
||||
? {
|
||||
durationMillis: Number(data.videoMediaMetadata.durationMillis),
|
||||
height: Number(data.videoMediaMetadata.height),
|
||||
width: Number(data.videoMediaMetadata.width),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const parsed = Schema_File.safeParse(file);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse file",
|
||||
error: parsed.error.message,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "File found",
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get readme file inside a folder
|
||||
* @param id - Folder ID to fetch, default is root folder
|
||||
*/
|
||||
export async function GetReadme(id: string | null = null): Promise<
|
||||
ActionResponseSchema<{
|
||||
type: "markdown" | "txt";
|
||||
content: string;
|
||||
} | null>
|
||||
> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterQuery: string = [
|
||||
"trashed = false",
|
||||
"(not mimeType contains 'folder')",
|
||||
`name = '${config.apiConfig.specialFile.readme}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
].join(" and ");
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No README found",
|
||||
data: null,
|
||||
};
|
||||
|
||||
let file;
|
||||
if (data.files.length === 1) {
|
||||
file = data.files[0];
|
||||
} else {
|
||||
file = data.files.find((file) => file.mimeType === "text/markdown");
|
||||
if (!file) {
|
||||
file = data.files.find((file) => file.mimeType === "application/vnd.google-apps.shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
if (!file)
|
||||
return {
|
||||
success: true,
|
||||
message: "No README found",
|
||||
data: null,
|
||||
};
|
||||
|
||||
switch (file.mimeType) {
|
||||
case "application/vnd.google-apps.shortcut":
|
||||
const { data: shortcutData } = await gdrive.files.get({
|
||||
fileId: file.id!,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
const parsedData = Schema_File_Shortcut.safeParse(shortcutData);
|
||||
if (!parsedData.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse shortcut data",
|
||||
error: parsedData.error.message,
|
||||
};
|
||||
|
||||
if (
|
||||
!parsedData.data.shortcutDetails.targetId ||
|
||||
(parsedData.data.shortcutDetails.targetMimeType !== "text/markdown" &&
|
||||
parsedData.data.shortcutDetails.targetMimeType !== "text/plain")
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
message: "Shortcut target is not a markdown file or plain text",
|
||||
data: null,
|
||||
};
|
||||
|
||||
const { data: shortcutContent } = await gdrive.files.get(
|
||||
{
|
||||
fileId: parsedData.data.shortcutDetails.targetId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "README found",
|
||||
data: {
|
||||
type: parsedData.data.shortcutDetails.targetMimeType === "text/markdown" ? "markdown" : "txt",
|
||||
content: shortcutContent as string,
|
||||
},
|
||||
};
|
||||
case "text/markdown":
|
||||
case "text/plain":
|
||||
const { data: content } = await gdrive.files.get(
|
||||
{
|
||||
fileId: file.id!,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "README found",
|
||||
data: {
|
||||
type: file.mimeType === "text/markdown" ? "markdown" : "txt",
|
||||
content: content as string,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
success: true,
|
||||
message: "No README found",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get banner file inside a folder
|
||||
* @param id - Folder ID to fetch, default is root folder
|
||||
*/
|
||||
export async function GetBanner(id: string | null = null): Promise<ActionResponseSchema<string | null>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterQuery: string = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name contains '${config.apiConfig.specialFile.banner}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
].join(" and ");
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField},parents)`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No banner found",
|
||||
data: null,
|
||||
};
|
||||
|
||||
const encryptedId = await encryptionService.encrypt(data.files[0]?.id ?? "");
|
||||
return {
|
||||
success: true,
|
||||
message: "Banner found",
|
||||
data: encryptedId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function CreateFileToken(id: string): Promise<ActionResponseSchema<string>> {
|
||||
return {
|
||||
success: true,
|
||||
message: "Token created",
|
||||
data: "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { COOKIES_NAME, COOKIES_OPTIONS, USE_CACHE } from "~/constant";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { PathValidationCache } from "~/lib/cache";
|
||||
import { toUrlPath } from "~/lib/utils";
|
||||
import { encryptionService, gdriveNoCache } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* Remove all saved passwords.
|
||||
*/
|
||||
export async function ClearSavedPasswords(): Promise<ActionResponseSchema> {
|
||||
const store = await cookies();
|
||||
if (store.has(COOKIES_NAME.indexPassword)) {
|
||||
store.delete(COOKIES_NAME.indexPassword);
|
||||
}
|
||||
if (store.has(COOKIES_NAME.folderPassword)) {
|
||||
store.delete(COOKIES_NAME.folderPassword);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "All saved passwords cleared",
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password of the index is correct.
|
||||
* Only used if the index is password-protected.
|
||||
*/
|
||||
export async function CheckIndexPassword(): Promise<ActionResponseSchema> {
|
||||
let response: ActionResponseSchema = {
|
||||
success: true,
|
||||
message: "Index is public",
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
// Skip if public
|
||||
if (!config.siteConfig.privateIndex) return response;
|
||||
|
||||
// Check if password is set
|
||||
if (!process.env.SITE_PASSWORD) {
|
||||
response = {
|
||||
success: false,
|
||||
message: "Password is not set",
|
||||
error:
|
||||
"Index password is not set, please set the `SITE_PASSWORD` environment variable, or disable `privateIndex` in the config file!",
|
||||
};
|
||||
return response;
|
||||
}
|
||||
const store = await cookies();
|
||||
|
||||
const password = store.get(COOKIES_NAME.indexPassword)?.value;
|
||||
if (!password) {
|
||||
response = {
|
||||
success: false,
|
||||
message: "Index is locked",
|
||||
error: "",
|
||||
};
|
||||
return response;
|
||||
}
|
||||
const decryptedPassword = await encryptionService.decrypt(password);
|
||||
|
||||
if (decryptedPassword !== process.env.SITE_PASSWORD) {
|
||||
response = {
|
||||
success: false,
|
||||
message: "Incorrect password",
|
||||
error: "Incorrect password, please try again!",
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
response = {
|
||||
success: true,
|
||||
message: "Index is unlocked",
|
||||
data: undefined,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index / full site password.
|
||||
* @param {string} password - The password to set.
|
||||
* @returns {ActionResponseSchema}
|
||||
*/
|
||||
export async function SetIndexPassword(password: string): Promise<ActionResponseSchema> {
|
||||
try {
|
||||
const store = await cookies();
|
||||
const encryptedPassword = await encryptionService.encrypt(password);
|
||||
store.set(COOKIES_NAME.indexPassword, encryptedPassword, COOKIES_OPTIONS);
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
data: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Actions/Password/SetIndexPassword] ${e.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to set password",
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password of the current folder is correct.
|
||||
*/
|
||||
export async function CheckPagePassword(
|
||||
paths: {
|
||||
path: string;
|
||||
id: string;
|
||||
}[],
|
||||
): Promise<ActionResponseSchema> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const pathsArray = USE_CACHE ? PathValidationCache.get(toUrlPath(paths)) ?? paths : paths;
|
||||
|
||||
const folderIds: string[] = [];
|
||||
for (const path of pathsArray) {
|
||||
const decryptedId = await encryptionService.decrypt(path.id);
|
||||
if (decryptedId === decryptedRootId) continue;
|
||||
folderIds.push(decryptedId);
|
||||
}
|
||||
|
||||
const filterQuery: string = [
|
||||
`name = '${config.apiConfig.specialFile.password}'`,
|
||||
`trashed = false`,
|
||||
`(${folderIds.map((id) => `'${id}' in parents`).join(" or ")})`,
|
||||
].join(" and ");
|
||||
|
||||
const findPasswordResponse = await gdriveNoCache.files.list({
|
||||
q: filterQuery,
|
||||
fields: "files(id,name,mimeType,parents)",
|
||||
pageSize: 1000,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!findPasswordResponse.data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: `No password file found on current path (${toUrlPath(paths)})`,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const protectedFolderPassword = findPasswordResponse.data.files
|
||||
.filter((item) => folderIds.includes(item.parents?.[0] ?? ""))
|
||||
?.shift();
|
||||
if (!protectedFolderPassword)
|
||||
return {
|
||||
success: true,
|
||||
message: `All folders on current path are public (${toUrlPath(paths)})`,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const store = await cookies();
|
||||
const storedPassword = JSON.parse(store.get(COOKIES_NAME.folderPassword)?.value ?? "{}") as Record<string, string>;
|
||||
|
||||
const currentFolderIndex = folderIds.findIndex((item) => item === protectedFolderPassword.parents?.[0]);
|
||||
const currentFolder = paths[currentFolderIndex]!;
|
||||
const currentFolderPath = toUrlPath(paths.slice(0, currentFolderIndex + 1));
|
||||
const currentFolderPassword = storedPassword[currentFolderPath];
|
||||
|
||||
if (!currentFolderPassword)
|
||||
return {
|
||||
success: false,
|
||||
message: "Folder is locked",
|
||||
error: `Please enter password for '${currentFolderPath}'`,
|
||||
};
|
||||
|
||||
const savedPasswordValue = await encryptionService.decrypt(currentFolderPassword);
|
||||
const { data: passwordFile } = await gdriveNoCache.files.get(
|
||||
{
|
||||
fileId: protectedFolderPassword.id!,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
if (!passwordFile)
|
||||
return {
|
||||
success: false,
|
||||
message: "Password file not found",
|
||||
error: `Failed to get password file for '${currentFolder.path}', please try again!`,
|
||||
};
|
||||
|
||||
if (savedPasswordValue !== passwordFile)
|
||||
return {
|
||||
success: false,
|
||||
message: "Incorrect password",
|
||||
error: `The password for '${currentFolder.path}' is incorrect`,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Folder is unlocked",
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the password for the current folder.
|
||||
* @param path - The path of the folder.
|
||||
* @param password - The password to set.
|
||||
*/
|
||||
export async function SetPagePassword(path: string, password: string): Promise<ActionResponseSchema> {
|
||||
try {
|
||||
const store = await cookies();
|
||||
const storedPassword = JSON.parse(store.get(COOKIES_NAME.folderPassword)?.value ?? "{}") as Record<string, string>;
|
||||
const encryptedPassword = await encryptionService.encrypt(password);
|
||||
|
||||
storedPassword[path] = encryptedPassword;
|
||||
store.set(COOKIES_NAME.folderPassword, JSON.stringify(storedPassword), COOKIES_OPTIONS);
|
||||
revalidatePath(path, "page");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
data: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Actions/Password/SetPagePassword] ${e.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to set password",
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use server";
|
||||
|
||||
import { USE_CACHE } from "~/constant";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { PathCache, PathValidationCache } from "~/lib/cache";
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* Get file paths from the root folder to the file.
|
||||
* @param {string} fileName - The file name.
|
||||
* @param {string} parentId - The parent ID of the file.
|
||||
* @returns {string} - The file path.
|
||||
*/
|
||||
export async function GetFilePaths(fileName: string, parentId?: string): Promise<ActionResponseSchema<string>> {
|
||||
const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
if (!decryptedRootId)
|
||||
return { success: false, message: "Failed to decrypt root folder ID", error: "Failed to decrypt root folder ID" };
|
||||
|
||||
const paths: string[] = [fileName];
|
||||
let parentIdTemp = parentId;
|
||||
while (parentIdTemp) {
|
||||
if (parentIdTemp === decryptedRootId) break;
|
||||
|
||||
if (PathCache.has(parentIdTemp) && USE_CACHE) {
|
||||
const cachedPath = PathCache.get(parentIdTemp)!;
|
||||
if (cachedPath) {
|
||||
paths.unshift(cachedPath.name);
|
||||
parentIdTemp = cachedPath.id;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: parentIdTemp,
|
||||
fields: "id,name,parents",
|
||||
});
|
||||
if (!data.name) break;
|
||||
|
||||
paths.unshift(data.name);
|
||||
if (USE_CACHE)
|
||||
PathCache.set(parentIdTemp, {
|
||||
id: data.parents?.[0] ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
parentIdTemp = data.parents?.[0];
|
||||
}
|
||||
|
||||
return { success: true, message: "File paths retrieved", data: paths.join("/") };
|
||||
}
|
||||
|
||||
type PathFetch = {
|
||||
index: number;
|
||||
path: string;
|
||||
data: {
|
||||
id: string;
|
||||
parents?: string;
|
||||
mimeType: string;
|
||||
}[];
|
||||
};
|
||||
/**
|
||||
* Validate paths and return the ID of each path.
|
||||
* @param paths - The paths to validate.
|
||||
* @returns {ActionResponseSchema<{ id: string; path: string; mimeType: string; }[]>} - The validated paths.
|
||||
*/
|
||||
export async function ValidatePaths(
|
||||
paths: string[],
|
||||
): Promise<ActionResponseSchema<{ id: string; path: string; mimeType: string }[]>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
if (PathValidationCache.has(paths.join("/")) && USE_CACHE) {
|
||||
const cachedPath = PathValidationCache.get(paths.join("/"))!;
|
||||
if (cachedPath) return { success: true, message: "Paths validated from cache", data: cachedPath };
|
||||
}
|
||||
|
||||
const promises: Promise<PathFetch | null>[] = [];
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const list = gdrive.files
|
||||
.list({
|
||||
q: `name = '${decodeURIComponent(path)}' and trashed = false`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (!data.files?.length) return null;
|
||||
|
||||
const object: PathFetch = {
|
||||
index,
|
||||
path,
|
||||
data: data.files.map((file) => ({
|
||||
id: file.id!,
|
||||
parents: file.parents?.[0],
|
||||
mimeType: file.mimeType!,
|
||||
})),
|
||||
};
|
||||
return object;
|
||||
});
|
||||
promises.push(list);
|
||||
}
|
||||
|
||||
const pathData = await Promise.all(promises);
|
||||
|
||||
// Try to find index of invalid path (null value from promise)
|
||||
// If found, return error message
|
||||
const invalidPathIndex = pathData.findIndex((path) => !path);
|
||||
if (invalidPathIndex !== -1)
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid path",
|
||||
error: `Failed to find path: ${paths[invalidPathIndex]}`,
|
||||
};
|
||||
const filteredPathData = pathData.filter((path) => path) as PathFetch[];
|
||||
|
||||
// Validate each path
|
||||
let isValid = true;
|
||||
let invalidPath: string | undefined;
|
||||
const validatedPaths: PathFetch[] = [];
|
||||
|
||||
for (const path of filteredPathData) {
|
||||
if (!isValid) break;
|
||||
if (!path.data.length) {
|
||||
isValid = false;
|
||||
invalidPath = path.path;
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for current path index
|
||||
* If it's 0 / the first path, make sure the parent is the root folder
|
||||
* If it's not, make sure the parent is the previous path's ID
|
||||
*/
|
||||
for (const item of path.data) {
|
||||
if (path.index === 0) {
|
||||
if (item.parents !== decryptedRootId && item.parents !== decryptedSharedDrive) break;
|
||||
validatedPaths.push(path);
|
||||
} else {
|
||||
const previousPath = validatedPaths[path.index - 1];
|
||||
if (!previousPath) break;
|
||||
if (item.parents !== previousPath.data?.[0]?.id) break;
|
||||
validatedPaths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (validatedPaths.length !== filteredPathData.length) {
|
||||
isValid = false;
|
||||
invalidPath = filteredPathData[validatedPaths.length]?.path;
|
||||
}
|
||||
if (!isValid)
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid path",
|
||||
error: invalidPath ? `Failed to validate path: ${invalidPath}` : "Failed when validating paths",
|
||||
};
|
||||
|
||||
const response: { path: string; id: string; mimeType: string }[] = [];
|
||||
for (const item of validatedPaths) {
|
||||
response.push({
|
||||
id: await encryptionService.encrypt(item.data[0]?.id ?? ""),
|
||||
path: decodeURIComponent(item.path),
|
||||
mimeType: item.data[0]?.mimeType ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
if (USE_CACHE) PathValidationCache.set(paths.join("/"), response);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Paths validated",
|
||||
data: response,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { type z } from "zod";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import { GetFilePaths } from "./paths";
|
||||
|
||||
export async function SearchFiles(query: string): Promise<ActionResponseSchema<z.infer<typeof Schema_File>[]>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and ");
|
||||
const filterQuery: string = [...config.apiConfig.defaultQuery, `name contains '${query}'`, filterName].join(" and ");
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField})`,
|
||||
orderBy: "name_natural desc",
|
||||
pageSize: config.apiConfig.searchResult,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No files found",
|
||||
data: [],
|
||||
};
|
||||
|
||||
const files: z.infer<typeof Schema_File>[] = [];
|
||||
for (const file of data.files) {
|
||||
files.push({
|
||||
encryptedId: await encryptionService.encrypt(file.id!),
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined,
|
||||
name: file.name!,
|
||||
mimeType: file.mimeType!,
|
||||
trashed: file.trashed ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension ?? undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink ?? undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = Schema_File.array().safeParse(files);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse search results",
|
||||
error: parsed.error.message,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Search completed",
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GetSearchResultPath(id: string): Promise<ActionResponseSchema<string>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: "id,name,parents",
|
||||
supportsAllDrives: isSharedDrive,
|
||||
});
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get file information",
|
||||
error: "Failed to get file information, might be due to invalid file ID!",
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = await GetFilePaths(data.name!, data.parents?.[0]);
|
||||
if (!filePath.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get file path",
|
||||
error: filePath.error,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "File path found",
|
||||
data: filePath.data,
|
||||
};
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import { icons } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { getting_started, migration, new_user, shared_drive } from "~/data/docs";
|
||||
|
||||
import { Icon, Markdown } from "~/components/Global";
|
||||
import { ConfigurationForm } from "~/components/Guide/Configuration";
|
||||
import { ThemeForm } from "~/components/Guide/Theme";
|
||||
import { Icon, Markdown } from "~/components/global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Sections = ["start", "new-user", "shared-drive", "migrating", "config", "theme"] as const;
|
||||
type Section = (typeof Sections)[number];
|
||||
@@ -45,9 +45,9 @@ export default function DeployGuidePage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative mx-auto w-full max-w-screen-desktop", "gap-3 p-3", "flex-grow-0", "flex flex-col")}>
|
||||
<div className={cn("relative mx-auto w-full max-w-screen-desktop", "gap-4 p-3", "flex-grow-0", "flex flex-col")}>
|
||||
<Alert className='bg-blue-50 text-blue-600 dark:bg-blue-950/25 dark:text-blue-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<Icon
|
||||
name='Info'
|
||||
className='size-5'
|
||||
@@ -137,7 +137,7 @@ Please set the \`showDeployGuide\` to false on the config files. Since it will t
|
||||
<Separator />
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
|
||||
+188
-100
@@ -1,54 +1,55 @@
|
||||
import { Metadata, ResolvedMetadata } from "next";
|
||||
import { type Metadata, type ResolvedMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer";
|
||||
import { Password } from "~/components/Layout";
|
||||
import { PreviewLayout } from "~/components/Preview";
|
||||
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
|
||||
import { Password } from "~/components/layout";
|
||||
import { PreviewLayout } from "~/components/preview";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
import { getFileType } from "~/lib/previewHelper";
|
||||
import { formatPathToBreadcrumb } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files";
|
||||
import { CheckPagePassword } from "~/actions/password";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
|
||||
import { CheckPassword, CheckPaths, GetBanner, GetFile, GetFiles, GetReadme } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import ErrorComponent from "../error";
|
||||
import DeployGuidePage from "./deploy";
|
||||
|
||||
export const revalidate = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
rest: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const isDeployGuide = (paths: string[]) => paths.length === 1 && paths[0] === "deploy" && config.showDeployGuide;
|
||||
|
||||
export async function generateMetadata({ params: { rest } }: Props, parent: ResolvedMetadata): Promise<Metadata> {
|
||||
if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return { title: "Deploy Guide" };
|
||||
export async function generateMetadata({ params }: Props, parent: ResolvedMetadata): Promise<Metadata> {
|
||||
const { rest } = await params;
|
||||
if (isDeployGuide(rest)) return { title: "Deploy Guide", description: "Read on how to deploy this project" };
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
const paths = await ValidatePaths(rest);
|
||||
if (!paths.success) return { title: "Not Found" };
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId) return { title: "Not Found" };
|
||||
const data = await GetFile(encryptedId);
|
||||
const currentPath = paths.data.pop();
|
||||
if (!currentPath?.id) return { title: "Not Found" };
|
||||
|
||||
const banner = await GetBanner(encryptedId);
|
||||
const banner = await GetBanner(currentPath.id);
|
||||
|
||||
return {
|
||||
title: data.name,
|
||||
description: data.mimeType?.includes("folder") ? `Browse ${data.name} files` : `View ${data.name}`,
|
||||
title: decodeURIComponent(currentPath.path),
|
||||
description: currentPath.mimeType.includes("folder")
|
||||
? `Browse ${currentPath.path} files`
|
||||
: `View ${currentPath.path}`,
|
||||
openGraph: {
|
||||
images: banner
|
||||
images: banner.success
|
||||
? [
|
||||
{
|
||||
url: `/api/og/${banner}`,
|
||||
url: `/api/og/${banner.data}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
@@ -58,98 +59,185 @@ export async function generateMetadata({ params: { rest } }: Props, parent: Reso
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RestPage({ params: { rest } }: Props) {
|
||||
if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return <DeployGuidePage />;
|
||||
export default async function RestPage({ params }: Props) {
|
||||
const { rest } = await params;
|
||||
if (isDeployGuide(rest)) return <DeployGuidePage />;
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
const paths = await ValidatePaths(rest);
|
||||
if (!paths.success) notFound();
|
||||
const unlocked = await CheckPassword(paths.data);
|
||||
|
||||
const unlocked = await CheckPagePassword(paths.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error(`No path returned from password checking${unlocked.message && `, ${unlocked.message}`}`);
|
||||
return (
|
||||
<Password
|
||||
path={unlocked.path}
|
||||
checkPaths={paths.data}
|
||||
errorMessage={unlocked.message}
|
||||
type='path'
|
||||
paths={paths.data}
|
||||
errorMessage={unlocked.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
const currentPath = paths.data[paths.data.length - 1];
|
||||
if (!currentPath) return <ErrorComponent error={new Error("Failed to get current path")} />;
|
||||
|
||||
const promise = [];
|
||||
|
||||
const { data: file } = await gdrive.files.get({
|
||||
fileId: await decryptData(encryptedId),
|
||||
fields: "mimeType, fileExtension",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!file.mimeType?.includes("folder")) {
|
||||
promise.push(GetFile(encryptedId));
|
||||
} else {
|
||||
promise.push(GetFiles({ id: encryptedId }));
|
||||
}
|
||||
promise.push(GetReadme(encryptedId));
|
||||
|
||||
const [data, readme] = await Promise.all(promise).then((values) => {
|
||||
const file = Schema_File.safeParse(values[0]);
|
||||
|
||||
if (file.success) {
|
||||
return values as [z.infer<typeof Schema_File>, string];
|
||||
} else {
|
||||
return values as [{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string }, string];
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<FilePath
|
||||
data={rest.map((item, index, array) => ({
|
||||
label: decodeURIComponent(item),
|
||||
href: index === array.length - 1 ? undefined : `${item}`,
|
||||
}))}
|
||||
/>
|
||||
const Layout: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => (
|
||||
<div className='flex h-fit w-full flex-col gap-4'>
|
||||
<FileBreadcrumb data={formatPathToBreadcrumb(paths.data)} />
|
||||
|
||||
<section
|
||||
slot='content'
|
||||
className='w-full'
|
||||
>
|
||||
{!("files" in data) ? (
|
||||
<PreviewLayout
|
||||
data={data}
|
||||
fileType={file.fileExtension && file.mimeType ? getFileType(file.fileExtension, file.mimeType) : "unknown"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<Actions />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Explorer
|
||||
encryptedId={encryptedId}
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (currentPath.mimeType.includes("folder")) {
|
||||
const [data, readme] = await Promise.all([ListFiles({ id: currentPath.id }), GetReadme(currentPath.id)]);
|
||||
if (!data.success) return <ErrorComponent error={new Error(data.error)} />;
|
||||
if (!readme.success) return <ErrorComponent error={new Error(readme.error)} />;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-4'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<FileActions />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-2 pt-0 tablet:p-4 tablet:pt-0'>
|
||||
<FileExplorerLayout
|
||||
encryptedId={currentPath.id}
|
||||
files={data.data.files}
|
||||
nextPageToken={data.data.nextPageToken ?? undefined}
|
||||
showBackButton
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{readme.data && (
|
||||
<FileReadme
|
||||
content={readme.data.content}
|
||||
title={`README.${readme.data.type === "markdown" ? "md" : "txt"}`}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const file = await GetFile(currentPath.id);
|
||||
if (!file.success) {
|
||||
if (file.error === "NotFound") notFound();
|
||||
return <ErrorComponent error={new Error(file.error)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PreviewLayout
|
||||
data={file.data!}
|
||||
fileType={
|
||||
file.data?.fileExtension && file.data?.mimeType
|
||||
? getFileType(file.data.fileExtension, file.data.mimeType)
|
||||
: "unknown"
|
||||
}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
// const paths = await CheckPaths(rest);
|
||||
// if (!paths.success) notFound();
|
||||
// const unlocked = await CheckPassword(paths.data);
|
||||
|
||||
// if (!unlocked.success) {
|
||||
// if (!unlocked.path)
|
||||
// throw new Error(`No path returned from password checking${unlocked.message && `, ${unlocked.message}`}`);
|
||||
// return (
|
||||
// <Password
|
||||
// path={unlocked.path}
|
||||
// checkPaths={paths.data}
|
||||
// errorMessage={unlocked.message}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
// const encryptedId = paths.data.pop()?.id;
|
||||
// if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
|
||||
// const promise = [];
|
||||
|
||||
// const { data: file } = await gdrive.files.get({
|
||||
// fileId: await decryptData(encryptedId),
|
||||
// fields: "mimeType, fileExtension",
|
||||
// supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
// });
|
||||
// if (!file.mimeType?.includes("folder")) {
|
||||
// promise.push(GetFile(encryptedId));
|
||||
// } else {
|
||||
// promise.push(GetFiles({ id: encryptedId }));
|
||||
// }
|
||||
// promise.push(GetReadme(encryptedId));
|
||||
|
||||
// const [data, readme] = await Promise.all(promise).then((values) => {
|
||||
// const file = Schema_File.safeParse(values[0]);
|
||||
|
||||
// if (file.success) {
|
||||
// return values as [z.infer<typeof Schema_File>, string];
|
||||
// } else {
|
||||
// return values as [{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string }, string];
|
||||
// }
|
||||
// });
|
||||
|
||||
// return (
|
||||
// <div className={cn("h-fit w-full", "flex flex-col gap-4")}>
|
||||
// <FileBreadcrumb
|
||||
// data={rest.map((item, index, array) => ({
|
||||
// label: decodeURIComponent(item),
|
||||
// href: index === array.length - 1 ? undefined : `${item}`,
|
||||
// }))}
|
||||
// />
|
||||
|
||||
// <section
|
||||
// slot='content'
|
||||
// className='w-full'
|
||||
// >
|
||||
// {!("files" in data) ? (
|
||||
// <PreviewLayout
|
||||
// data={data}
|
||||
// fileType={file.fileExtension && file.mimeType ? getFileType(file.fileExtension, file.mimeType) : "unknown"}
|
||||
// />
|
||||
// ) : (
|
||||
// <>
|
||||
// <Card>
|
||||
// <CardHeader className='pb-0'>
|
||||
// <div className='flex w-full items-center justify-between gap-4'>
|
||||
// <CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
// <FileActions />
|
||||
// </div>
|
||||
// <Separator />
|
||||
// </CardHeader>
|
||||
// <CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
// <FileExplorerLayout
|
||||
// encryptedId={encryptedId}
|
||||
// files={data.files}
|
||||
// nextPageToken={data.nextPageToken}
|
||||
// />
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// </>
|
||||
// )}
|
||||
// </section>
|
||||
|
||||
// {readme && (
|
||||
// <FileReadme
|
||||
// content={readme}
|
||||
// title={"README.md"}
|
||||
// />
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { encryptData } from "~/utils/encryptionHelper";
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -11,13 +11,13 @@ export async function GET(request: NextRequest) {
|
||||
const key = sp.get("key");
|
||||
if (!query) return new NextResponse("Add query parameter 'q' with the value to encrypt", { status: 400 });
|
||||
|
||||
const encrypted = await encryptData(query, key || process.env.ENCRYPTION_KEY);
|
||||
const encrypted = await encryptionService.encrypt(query, key ?? undefined);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: key ? "Encrypted with provided key" : "Encrypted with environment key",
|
||||
value: encrypted,
|
||||
key,
|
||||
key: key ?? process.env.ENCRYPTION_KEY,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import { GetFile } from "~/actions/files";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
|
||||
// TODO: Barebone implementation, add more protection like token, etc
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = new URL(req.nextUrl).searchParams;
|
||||
const url = sp.get("url");
|
||||
try {
|
||||
if (!url)
|
||||
throw new Error("[400] Invalid raw api usage", {
|
||||
cause: "No URL provided",
|
||||
});
|
||||
const paths = url.split("/").filter(Boolean);
|
||||
const validatePaths = await ValidatePaths(paths);
|
||||
if (!validatePaths.success)
|
||||
throw new Error(`[404] ${validatePaths.message}`, {
|
||||
cause: validatePaths.error,
|
||||
});
|
||||
|
||||
const currentFile = validatePaths.data.pop();
|
||||
if (!currentFile)
|
||||
throw new Error("[404] File not found", {
|
||||
cause: "Failed to get current file",
|
||||
});
|
||||
if (["image", "video", "audio"].every((type) => !currentFile.mimeType.includes(type)))
|
||||
throw new Error("[400] Invalid file type", {
|
||||
cause: "Raw link only available for video, image, and audio files",
|
||||
});
|
||||
const file = await GetFile(currentFile.id);
|
||||
if (!file.success)
|
||||
throw new Error(`[500] ${file.message}`, {
|
||||
cause: file.error,
|
||||
});
|
||||
if (!file.data?.encryptedWebContentLink)
|
||||
throw new Error("[500] No download link found", {
|
||||
cause: "No download link found",
|
||||
});
|
||||
|
||||
const decryptedLink = await encryptionService.decrypt(file.data.encryptedWebContentLink);
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: decryptedLink,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
const message = e.message.replace(/\[.*\]/, "").trim();
|
||||
const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
scope: "api/raw",
|
||||
message,
|
||||
cause: e.cause ?? "Unknown",
|
||||
},
|
||||
{
|
||||
status: Number(status),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { IS_DEV } from "~/constant";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
|
||||
import { CheckDownloadToken, CheckPassword, CheckPaths, RedirectSearchFile } from "actions";
|
||||
import config from "config";
|
||||
@@ -25,7 +25,7 @@ export async function GET(
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
// Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
if (!IS_DEV && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ If you've already entered the password, please make sure your browser is not blo
|
||||
let rangeStart = 0;
|
||||
let rangeEnd = Math.min(chunkSize, fileSize - 1);
|
||||
const rangeRegex = /bytes=(\d+)-(\d+)?/;
|
||||
const rangeSize = ranges.match(rangeRegex);
|
||||
const rangeSize = rangeRegex.exec(ranges);
|
||||
if (rangeSize) {
|
||||
rangeStart = parseInt(rangeSize[1], 10);
|
||||
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { IS_DEV } from "~/constant";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
encryptedId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params: { encryptedId } }: Props) {
|
||||
export async function GET(request: NextRequest, { params }: Props) {
|
||||
const { encryptedId } = await params;
|
||||
try {
|
||||
const searchParams = new URL(request.nextUrl).searchParams;
|
||||
const size = searchParams.get("size") || "512";
|
||||
|
||||
// Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
if (!IS_DEV && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
@@ -27,10 +28,7 @@ export async function GET(request: NextRequest, { params: { encryptedId } }: Pro
|
||||
throw new Error("Invalid size");
|
||||
}
|
||||
|
||||
const defaultImage = NextResponse.redirect(new URL("/og.png", config.basePath), {
|
||||
status: 302,
|
||||
});
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const decryptedId = await encryptionService.decrypt(encryptedId);
|
||||
|
||||
const url = `https://drive.google.com/thumbnail?id=${decryptedId}&sz=w${size}`;
|
||||
|
||||
|
||||
+17
-10
@@ -1,38 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset?: () => void }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-3")}>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-screen-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-2",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={"2rem"}
|
||||
className='text-destructive'
|
||||
className='size-10 stroke-destructive'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-center text-destructive'>Something went wrong</span>
|
||||
<span className='text-center text-sm text-destructive'>More details can be found in the console</span>
|
||||
</div>
|
||||
<code className='w-full whitespace-pre-wrap rounded-[var(--radius)] bg-muted px-3 py-1.5 text-sm text-muted-foreground'>
|
||||
<code className='my-4 w-full whitespace-pre-wrap rounded-[var(--radius)] bg-muted px-3 py-1.5 text-sm text-muted-foreground'>
|
||||
{error.message}
|
||||
</code>
|
||||
|
||||
<div className='flex w-full flex-col items-center gap-3 pt-8'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => reset()}
|
||||
onClick={() => {
|
||||
if (reset) reset();
|
||||
else router.refresh();
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/* Version: ${pkg.version} */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Shadcn/ui theme */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 89.8%;
|
||||
/* --ring: 0 0% 3.9%; */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 14.9%;
|
||||
/* --ring: 0 0% 83.1%; */
|
||||
}
|
||||
|
||||
div.preview * {
|
||||
@apply !transition-none;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@layer base {
|
||||
:root {
|
||||
/* @apply text-[14px] tablet:text-[16px]; */
|
||||
@apply text-[100%];
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
/* @apply outline outline-1 outline-red-500; */
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-1.5 w-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-primary/5;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-primary/25 hover:bg-primary/50;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
}
|
||||
h2 {
|
||||
@apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
|
||||
}
|
||||
h3 {
|
||||
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
h4 {
|
||||
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||
}
|
||||
.paragraph {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
}
|
||||
blockquote {
|
||||
@apply mt-6 border-l-2 pl-6 italic;
|
||||
}
|
||||
ul {
|
||||
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||
}
|
||||
.lead {
|
||||
@apply text-xl text-muted-foreground;
|
||||
}
|
||||
.large {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
.muted {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
small {
|
||||
@apply text-sm font-medium leading-none;
|
||||
}
|
||||
}
|
||||
+45
-30
@@ -1,19 +1,19 @@
|
||||
import { Metadata } from "next";
|
||||
import { type Metadata } from "next";
|
||||
import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google";
|
||||
import "plyr-react/plyr.css";
|
||||
import { BASE_URL } from "~/constant";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { Footer, Navbar, Password, ThemeProvider } from "~/components/Layout";
|
||||
import ToTop from "~/components/Layout/ToTop";
|
||||
import { Footer, Navbar, Password, Provider, ToTop } from "~/components/layout";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatFooter } from "~/utils/footerFormatter";
|
||||
import { cn, formatFooterContent } from "~/lib/utils";
|
||||
|
||||
import { CheckIndexPassword } from "~/actions/password";
|
||||
import "~/styles/globals.css";
|
||||
import "~/styles/markdown.css";
|
||||
|
||||
import { CheckSitePassword } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import "./globals.css";
|
||||
import "./markdown.css";
|
||||
|
||||
const sourceSans3 = Source_Sans_3({
|
||||
weight: ["300", "400", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
@@ -37,10 +37,10 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(config.basePath),
|
||||
metadataBase: new URL(BASE_URL),
|
||||
title: {
|
||||
default: config.siteConfig.siteName,
|
||||
template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) || "%s",
|
||||
template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) ?? "%s",
|
||||
},
|
||||
description: config.siteConfig.siteDescription,
|
||||
authors: config.siteConfig.siteAuthor
|
||||
@@ -54,13 +54,13 @@ export const metadata: Metadata = {
|
||||
url: config.siteConfig.favIcon,
|
||||
},
|
||||
],
|
||||
keywords: ["gdrive", "index", "nextjs", "reactjs"],
|
||||
keywords: ["gdrive", "index", "nextjs", "reactjs", "google drive"],
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: config.siteConfig.siteName,
|
||||
images: [
|
||||
{
|
||||
url: "/og.png",
|
||||
url: "/og.webp",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
@@ -74,29 +74,42 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
let unlocked: Awaited<ReturnType<typeof CheckSitePassword>> = {
|
||||
let unlocked: ActionResponseSchema = {
|
||||
success: true,
|
||||
message: "",
|
||||
message: "Index is public",
|
||||
data: undefined,
|
||||
};
|
||||
if (config.siteConfig.privateIndex) {
|
||||
unlocked = await CheckSitePassword();
|
||||
unlocked = await CheckIndexPassword();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<html
|
||||
lang='en'
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={cn(
|
||||
"h-full bg-background font-sans text-foreground",
|
||||
"h-full min-h-screen bg-background stroke-foreground font-sans text-foreground",
|
||||
jetbrainsMono.variable,
|
||||
sourceSans3.variable,
|
||||
outfit.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
<Provider
|
||||
theme={{
|
||||
attribute: "class",
|
||||
defaultTheme: "system",
|
||||
enableSystem: true,
|
||||
disableTransitionOnChange: true,
|
||||
}}
|
||||
tooltip={{
|
||||
delayDuration: 250,
|
||||
}}
|
||||
toaster={{
|
||||
position: config.siteConfig.toaster?.position,
|
||||
duration: config.siteConfig.toaster?.duration,
|
||||
}}
|
||||
>
|
||||
<Navbar />
|
||||
<main
|
||||
@@ -104,22 +117,24 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-screen-desktop",
|
||||
"relative left-0 top-0",
|
||||
"flex flex-grow flex-col gap-3 p-6",
|
||||
"flex flex-grow flex-col gap-4 px-2 py-6 mobile:px-3 tablet:px-4",
|
||||
"tablet:gap-6",
|
||||
)}
|
||||
>
|
||||
{config.siteConfig.privateIndex && !unlocked.success ? (
|
||||
<Password
|
||||
path='global'
|
||||
errorMessage={unlocked.message}
|
||||
/>
|
||||
<>
|
||||
<Password
|
||||
type='global'
|
||||
errorMessage={unlocked.error}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</main>
|
||||
{config.siteConfig.footer && <Footer content={formatFooter(config.siteConfig.footer)} />}
|
||||
<Footer content={formatFooterContent(config.siteConfig.footer ?? [])} />
|
||||
<ToTop />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Loader } from "~/components/Global";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
|
||||
export default function RootLoading() {
|
||||
return <Loader message='Loading data...' />;
|
||||
return <PageLoader message='Loading data...' />;
|
||||
}
|
||||
|
||||
+32
-2
@@ -1,5 +1,35 @@
|
||||
import { NotFound } from "~/components/Layout";
|
||||
"use client";
|
||||
|
||||
import { Status } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex h-full w-full max-w-screen-md flex-grow flex-col items-center justify-center gap-12'>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="Can't find the file or folder you're looking for"
|
||||
/>
|
||||
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<Button
|
||||
className='grow'
|
||||
variant={"outline"}
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
className='grow'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-21
@@ -1,21 +1,23 @@
|
||||
import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer";
|
||||
import { GetReadme, ListFiles } from "~/actions/files";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { GetFiles, GetReadme } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import ErrorComponent from "./error";
|
||||
|
||||
export const revalidate = 60;
|
||||
// export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RootPage() {
|
||||
const [data, readme] = await Promise.all([GetFiles({}), GetReadme(undefined)]);
|
||||
const [data, readme] = await Promise.all([ListFiles(), GetReadme()]);
|
||||
if (!data.success) return <ErrorComponent error={new Error(data.error)} />;
|
||||
if (!readme.success) return <ErrorComponent error={new Error(readme.error)} />;
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<FilePath data={[]} />
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-4")}>
|
||||
<FileBreadcrumb />
|
||||
|
||||
<section
|
||||
slot='content'
|
||||
@@ -23,27 +25,26 @@ export default async function RootPage() {
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<div className='flex w-full items-center justify-between gap-4'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<Actions />
|
||||
<FileActions />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Explorer
|
||||
|
||||
<CardContent className='p-2 pt-0 tablet:p-4 tablet:pt-0'>
|
||||
<FileExplorerLayout
|
||||
encryptedId={config.apiConfig.rootFolder}
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
root
|
||||
files={data.data.files}
|
||||
nextPageToken={data.data.nextPageToken ?? undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
{readme.data && (
|
||||
<FileReadme
|
||||
content={readme.data.content}
|
||||
title={`README.${readme.data.type === "markdown" ? "md" : "txt"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { RedirectSearchFile, SearchFile } from "actions";
|
||||
|
||||
export default function Actions() {
|
||||
const loading = useLoading();
|
||||
const { layout, setLayout } = useContext(LayoutContext);
|
||||
|
||||
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
|
||||
const [snap, setSnap] = useState<number | string | null>(0.3);
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState<string>("");
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<string>("");
|
||||
const [searchResults, setSearchResults] = useState<z.infer<typeof Schema_File>[]>([]);
|
||||
const [nextPageToken, setNextPageToken] = useState<string>();
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInput) {
|
||||
setSearchLoading(true);
|
||||
setDebouncedSearchInput("");
|
||||
setSearchError("");
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchInput(searchInput);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchInput]);
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchInput) return;
|
||||
onSearch();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const onSearch = async () => {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const data = await SearchFile(debouncedSearchInput, nextPageToken);
|
||||
setSearchResults(data.files);
|
||||
setNextPageToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setSearchError(e.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' /> <Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='actions'
|
||||
className={cn("w-fit", "flex items-center justify-end gap-1.5")}
|
||||
>
|
||||
<ResponsiveDropdown
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
trigger={
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='flex w-24 items-center justify-between gap-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>{layout === "grid" ? "Grid" : "List"}</span>
|
||||
</div>
|
||||
{isDesktop && (
|
||||
<Icon
|
||||
name='ChevronDown'
|
||||
className={cn("transition", layoutOpen && "rotate-180")}
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
mobile={{
|
||||
title: "File Layout",
|
||||
description: "Choose a layout for the files explorer",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={layout === "grid" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
className='justify-start gap-3'
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>Grid</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={layout === "list" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
className='justify-start gap-3'
|
||||
>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>List</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "grid",
|
||||
icon: "LayoutGrid",
|
||||
title: "Grid",
|
||||
onClick: () => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
},
|
||||
className: cn(layout === "grid" && "bg-primary text-primary-foreground"),
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "list",
|
||||
icon: "LayoutList",
|
||||
title: "List",
|
||||
onClick: () => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
},
|
||||
className: cn(layout === "list" && "bg-primary text-primary-foreground"),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search for files in your drive</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-1.5'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-full max-h-[66dvh] flex-grow flex-col gap-1.5 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<Loader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
shouldScaleBackground
|
||||
fadeFromIndex={0}
|
||||
snapPoints={[0.3, 1]}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className='h-full max-h-[95%]'>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Search</DrawerTitle>
|
||||
<DrawerDescription>Search for files in your drive</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-1.5 px-4'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-fit flex-grow flex-col gap-1.5 overflow-y-auto px-4'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<Loader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"cursor-pointer transition",
|
||||
)}
|
||||
onClick={async () => {
|
||||
toast.loading("Getting file path...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const paths = await RedirectSearchFile(data.encryptedId);
|
||||
router.push(`/${paths}`);
|
||||
toast.success("Redirecting...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message, {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex w-full flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{data.fileExtension ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") : data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Icon, Status } from "~/components/global";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { useLayout } from "~/context/layoutContext";
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { getPreviewIcon } from "~/lib/previewHelper";
|
||||
import { bytesToReadable, cn, durationToReadable, formatDate } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import { GetSearchResultPath, SearchFiles } from "~/actions/search";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
|
||||
export default function FileActions() {
|
||||
const loading = useLoading();
|
||||
const { layout, setLayout, isPending } = useLayout();
|
||||
|
||||
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
|
||||
const [snap, setSnap] = useState<number | string | null>(0.3);
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState<string>("");
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<string>("");
|
||||
const [searchResults, setSearchResults] = useState<z.infer<typeof Schema_File>[]>([]);
|
||||
|
||||
const { isDesktop } = useResponsive();
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInput) {
|
||||
setSearchLoading(true);
|
||||
setDebouncedSearchInput("");
|
||||
setSearchError("");
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchInput(searchInput);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchInput]);
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchInput) return;
|
||||
void onSearch();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const onSearch = async () => {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const results = await SearchFiles(debouncedSearchInput);
|
||||
if (!results.success) throw new Error(results.error);
|
||||
setSearchResults(results.data);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Search Error] ${e.message}`);
|
||||
setSearchError(e.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-2")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' /> <Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='actions'
|
||||
className={cn("w-fit", "flex items-center justify-end gap-2")}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<>
|
||||
<DropdownMenu
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={"outline"}>
|
||||
<Icon name={layout === "grid" ? "LayoutGrid" : "LayoutList"} />
|
||||
{layout === "grid" ? "Grid" : "List"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={isPending ?? layout === "grid"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
Grid
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isPending ?? layout === "list"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
List
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSnap(0.275);
|
||||
setTimeout(() => {
|
||||
setSearchInput("");
|
||||
}, 300);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search for files in your drive</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-2'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-full max-h-[66dvh] flex-grow flex-col gap-2 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<Status
|
||||
icon='LoaderCircle'
|
||||
iconClassName='animate-spin'
|
||||
message='Searching...'
|
||||
/>
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Drawer
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon name={layout === "grid" ? "LayoutGrid" : "LayoutList"} />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>File Layout</DrawerTitle>
|
||||
<DrawerDescription>Choose a layout for the files explorer</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4 md:px-0'>
|
||||
<Button
|
||||
variant={layout === "grid" ? "secondary" : "outline"}
|
||||
size={"default"}
|
||||
className='w-full'
|
||||
disabled={isPending ?? layout === "grid"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className='capitalize'>Grid</span>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={layout === "list" ? "secondary" : "outline"}
|
||||
size={"default"}
|
||||
className='w-full'
|
||||
disabled={isPending ?? layout === "list"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className='capitalize'>List</span>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSnap(0.275);
|
||||
setTimeout(() => {
|
||||
setSearchInput("");
|
||||
}, 300);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
shouldScaleBackground
|
||||
fadeFromIndex={0}
|
||||
snapPoints={[0.275, 1]}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className='h-full max-h-[95%]'>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Search</DrawerTitle>
|
||||
<DrawerDescription>Search for files in your drive</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-4 px-4 md:px-0'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-2'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-fit flex-grow flex-col gap-2 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<PageLoader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const router = useRouter();
|
||||
|
||||
const useThumbnail = useMemo<boolean>(
|
||||
() => !!(data.thumbnailLink && (data.mimeType.includes("image") || data.mimeType.includes("video"))),
|
||||
[data],
|
||||
);
|
||||
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState<boolean>(true);
|
||||
|
||||
const onOpen = useCallback(async () => {
|
||||
toast.loading("Getting file path...", {
|
||||
description: "Might take a while for deep nested file",
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const paths = await GetSearchResultPath(data.encryptedId);
|
||||
if (!paths.success) throw new Error(paths.error);
|
||||
toast.success("Redirecting...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
router.push(`/${paths.data}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Redirect Error] ${e.message}`);
|
||||
toast.error(e.message, {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
}, [data.encryptedId, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='file-search-result'
|
||||
title={data.name}
|
||||
className='group flex cursor-pointer items-center overflow-hidden rounded-lg border bg-card text-card-foreground shadow transition hover:border-accent-foreground'
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* Thumbnail / Icon */}
|
||||
<div className='relative grid aspect-square h-full w-14 shrink-0 place-items-center overflow-hidden mobile:w-16 tablet:w-20'>
|
||||
{useThumbnail ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
className='absolute -z-0 h-full w-full shrink-0 grow-0 object-cover object-center opacity-50 blur-sm'
|
||||
/>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
onLoad={() => {
|
||||
if (thumbnailUrl.endsWith("size=2")) {
|
||||
setThumbnailUrl(`/api/thumb/${data.encryptedId}`);
|
||||
setThumbnailLoading(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-0 w-full shrink-0 grow-0 object-contain object-center transition duration-500 ease-in-out group-hover:scale-105",
|
||||
thumbnailLoading ? "blur-lg" : "blur-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
{data.mimeType.includes("video") && (
|
||||
<div className='absolute inset-0 z-0 grid h-full w-full place-items-center'>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='rounded-full group-hover:bg-accent group-hover:stroke-accent-foreground group-hover:text-accent-foreground'
|
||||
>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='fill-current'
|
||||
/>
|
||||
</Button>
|
||||
<div className='absolute bottom-0.5 right-0.5 z-0 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground shadow-sm mobile:bottom-1 mobile:right-1 mobile:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension ?? "", data.mimeType)
|
||||
}
|
||||
className={"size-6 stroke-muted-foreground"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className='flex h-full grow flex-col justify-start p-2'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap text-pretty break-all text-base mobile:text-sm'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`\.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='inline-flex items-center gap-1 text-xs text-muted-foreground'>
|
||||
{/* Type */}
|
||||
{data.mimeType.includes("folder") ? (
|
||||
"Folder"
|
||||
) : config.siteConfig.showFileExtension ? (
|
||||
<></>
|
||||
) : (
|
||||
data.fileExtension ?? "Unknown format"
|
||||
)}
|
||||
{/* Slash */}
|
||||
{!data.mimeType.includes("folder") && !config.siteConfig.showFileExtension && (
|
||||
<Icon
|
||||
name='Slash'
|
||||
className='size-3'
|
||||
/>
|
||||
)}
|
||||
{/* Size */}
|
||||
{!data.mimeType.includes("folder") && bytesToReadable(data.size ?? 0)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>{formatDate(data.modifiedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/global";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { type Schema_Breadcrumb } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import {
|
||||
ResponsiveDropdownMenu,
|
||||
ResponsiveDropdownMenuContent,
|
||||
ResponsiveDropdownMenuItem,
|
||||
ResponsiveDropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu.responsive";
|
||||
|
||||
type Props = {
|
||||
data?: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function FileBreadcrumb({ data }: Props) {
|
||||
const loading = useLoading();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [path] = useState<z.infer<typeof Schema_Breadcrumb>[]>(data ?? []);
|
||||
|
||||
if (loading) return <Skeleton className='my-2 h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='file-path'
|
||||
className='flex w-full flex-nowrap items-center px-3'
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Root */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={"1rem"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{!!path.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{path.length >= config.siteConfig.breadcrumbMax && (
|
||||
<>
|
||||
<ResponsiveDropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<ResponsiveDropdownMenuTrigger asChild>
|
||||
<BreadcrumbLink asChild>
|
||||
<BreadcrumbEllipsis className='w-4 cursor-pointer' />
|
||||
</BreadcrumbLink>
|
||||
</ResponsiveDropdownMenuTrigger>
|
||||
<ResponsiveDropdownMenuContent
|
||||
header={{
|
||||
title: "Navigate",
|
||||
description: "Navigate to parent directories",
|
||||
}}
|
||||
>
|
||||
{path.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => (
|
||||
<ResponsiveDropdownMenuItem
|
||||
key={`breadcrumb-${item.label}/${item.href}`}
|
||||
closeOnSelect
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</ResponsiveDropdownMenuItem>
|
||||
))}
|
||||
</ResponsiveDropdownMenuContent>
|
||||
</ResponsiveDropdownMenu>
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{path.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${path
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import { type TLayout } from "~/context/layoutContext";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { getPreviewIcon } from "~/lib/previewHelper";
|
||||
import { bytesToReadable, cn, durationToReadable, formatDate } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
layout: TLayout;
|
||||
};
|
||||
export default function FileItem({ data, layout }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data.name, pathname]);
|
||||
|
||||
const contextButtonRef = useRef<HTMLDivElement>(null);
|
||||
const copyTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const useThumbnail = useMemo<boolean>(
|
||||
() => !!(data.thumbnailLink && (data.mimeType.includes("image") || data.mimeType.includes("video"))),
|
||||
[data],
|
||||
);
|
||||
// Using proxied URL to avoid CORS issues
|
||||
// Also using state to load smaller thumbnail first
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState<boolean>(true);
|
||||
|
||||
// Unused for now
|
||||
// Might be added on next version (?)
|
||||
const [isShareDialogOpen, setShareDialogOpen] = useState<boolean>(false);
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
|
||||
const onOpenNewTab = useCallback(async () => {
|
||||
void 0;
|
||||
}, []);
|
||||
const onCopy = useCallback(async () => {
|
||||
if (copyTimeout.current) clearTimeout(copyTimeout.current);
|
||||
|
||||
setCopyStatus("loading");
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
toast.success(`Link to ${data.mimeType.includes("folder") ? "folder" : "file"} copied!`, {
|
||||
description: <span className='line-clamp-2 whitespace-pre-wrap break-all'>{data.name}</span>,
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
setCopyStatus("success");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[onCopy] ${e.message}`);
|
||||
toast.error(e.message, {
|
||||
description: <span className='line-clamp-2 whitespace-pre-wrap break-all'>{data.name}</span>,
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
setCopyStatus("error");
|
||||
} finally {
|
||||
copyTimeout.current = setTimeout(() => {
|
||||
setCopyStatus("idle");
|
||||
}, 3000);
|
||||
}
|
||||
}, [data.encryptedId, data.mimeType, data.name, filePath]);
|
||||
const onShare = useCallback(async () => {
|
||||
setShareDialogOpen(true);
|
||||
}, []);
|
||||
const onDownload = useCallback(async () => {
|
||||
void 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
slot='file-item'
|
||||
ref={contextButtonRef}
|
||||
title={data.name}
|
||||
onClick={() => {
|
||||
router.push(filePath);
|
||||
}}
|
||||
className={cn(
|
||||
"group grid h-full w-full cursor-pointer overflow-hidden rounded-lg border bg-card text-card-foreground shadow transition hover:border-accent-foreground",
|
||||
layout === "list" && "flex items-center",
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail / Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid aspect-video h-24 w-full shrink-0 place-items-center overflow-hidden bg-background mobile:h-28 tablet:h-32",
|
||||
layout === "list" && "aspect-square h-20 w-20 mobile:h-24 mobile:w-24 tablet:h-28 tablet:w-28",
|
||||
)}
|
||||
>
|
||||
{useThumbnail ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
className={cn(
|
||||
"absolute -z-0 h-24 w-full shrink-0 grow-0 object-cover object-center opacity-50 blur-sm mobile:h-28 tablet:h-32",
|
||||
layout === "list" && "h-20 mobile:h-24 tablet:h-28",
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
onLoad={() => {
|
||||
if (thumbnailUrl.endsWith("?size=2")) {
|
||||
setThumbnailUrl(`/api/thumb/${data.encryptedId}`);
|
||||
setThumbnailLoading(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-0 h-24 w-full shrink-0 grow-0 object-contain object-center transition duration-500 ease-in-out group-hover:scale-105 mobile:h-28 tablet:h-32",
|
||||
layout === "list" && "h-20 mobile:h-24 tablet:h-28",
|
||||
thumbnailLoading ? "blur-lg" : "blur-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Play Icon */}
|
||||
{data.mimeType.includes("video") && (
|
||||
<div className='absolute inset-0 z-0 grid h-full w-full place-items-center'>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='rounded-full group-hover:bg-accent group-hover:stroke-accent-foreground group-hover:text-accent-foreground'
|
||||
>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='fill-current'
|
||||
/>
|
||||
</Button>
|
||||
<div className='absolute bottom-0.5 right-0.5 z-0 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground shadow-sm mobile:bottom-1 mobile:right-1 mobile:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension ?? "", data.mimeType)
|
||||
}
|
||||
className={cn("size-10 stroke-muted-foreground", layout === "list" && "size-8")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full grow items-start justify-between gap-2 p-2",
|
||||
layout === "list" && "items-center",
|
||||
)}
|
||||
>
|
||||
<div className='flex h-full grow flex-col justify-between gap-2'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all text-base mobile:text-sm'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='inline-flex items-center gap-1 text-xs text-muted-foreground'>
|
||||
{/* Type */}
|
||||
{data.mimeType.includes("folder") ? (
|
||||
"Folder"
|
||||
) : config.siteConfig.showFileExtension ? (
|
||||
<></>
|
||||
) : (
|
||||
data.fileExtension ?? "Unknown format"
|
||||
)}
|
||||
{/* Slash */}
|
||||
{!data.mimeType.includes("folder") && !config.siteConfig.showFileExtension && (
|
||||
<Icon
|
||||
name='Slash'
|
||||
className='size-3'
|
||||
/>
|
||||
)}
|
||||
{/* Size */}
|
||||
{!data.mimeType.includes("folder") && bytesToReadable(data.size ?? 0)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>{formatDate(data.modifiedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
className={cn("h-8 w-8 shrink-0 rounded-full", layout === "list" && "h-10 w-10")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const menuEvent = new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
buttons: 2,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
contextButtonRef.current?.dispatchEvent(menuEvent);
|
||||
}}
|
||||
>
|
||||
<Icon name='EllipsisVertical' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onOpenNewTab}>
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
className='mr-2'
|
||||
/>
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onCopy}>
|
||||
<Icon
|
||||
name='Copy'
|
||||
className='mr-2'
|
||||
/>
|
||||
Copy link
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={onDownload}
|
||||
disabled={data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-2'
|
||||
/>
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* <ResponsiveDialog
|
||||
open={isShareDialogOpen}
|
||||
onOpenChange={setShareDialogOpen}
|
||||
>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Share link</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
Share the link with others to give them access to this file.
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
|
||||
<ResponsiveDialogBody className='flex flex-col gap-4'>
|
||||
<pre className='flex w-full items-center justify-between gap-2 rounded-md bg-muted p-2 text-muted-foreground'>
|
||||
<code className='grow select-all text-sm'>{new URL(filePath, config.basePath).toString()}</code>
|
||||
<LoadingButton
|
||||
loading={copyStatus === "loading"}
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Icon
|
||||
name={copyStatus === "idle" ? "Copy" : copyStatus === "success" ? "Check" : "X"}
|
||||
className={cn(
|
||||
"transition",
|
||||
copyStatus === "success" && "stroke-green-600",
|
||||
copyStatus === "error" && "stroke-destructive",
|
||||
)}
|
||||
/>
|
||||
</LoadingButton>
|
||||
</pre>
|
||||
|
||||
<Separator className='bg-input' />
|
||||
|
||||
<div className='grid gap-2 tablet:grid-cols-4'>
|
||||
{["facebook", "twitter", "whatsapp"].map((platform) => (
|
||||
<Link
|
||||
key={platform}
|
||||
href={shareUrl[platform as keyof typeof shareUrl]!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn("flex items-center justify-center gap-2 rounded-md bg-muted p-2", "hover:bg-muted/50")}
|
||||
>
|
||||
<span>{platform}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ResponsiveDialogBody>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</ResponsiveDialogClose>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { FileItem } from "~/components/explorer";
|
||||
import { Icon, Status } from "~/components/global";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import { Button, LoadingButton } from "~/components/ui/button";
|
||||
|
||||
import { useLayout } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import { ListFiles } from "~/actions/files";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
encryptedId: string;
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
showBackButton?: boolean;
|
||||
};
|
||||
export default function FileExplorerLayout({ encryptedId, files, nextPageToken, showBackButton }: Props) {
|
||||
const { layout, isPending } = useLayout();
|
||||
const loading = useLoading();
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname.split("/").slice(0, -1).join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [filesList, setFilesList] = useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [isLoadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const data = await ListFiles({ id: encryptedId, pageToken: nextToken });
|
||||
if (!data.success) throw new Error(data.error);
|
||||
|
||||
const uniqueData = [...filesList, ...data.data.files].filter(
|
||||
(item, index, self) => index === self.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
|
||||
setFilesList(uniqueData);
|
||||
setNextToken(data.data.nextPageToken ?? undefined);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[OnLoadMore] ${e.message}`);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || isPending) return <PageLoader message='Loading files...' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='explorer'
|
||||
className={cn("mx-auto w-full max-w-screen-desktop", "flex flex-grow-0 flex-col gap-4 px-2 py-4")}
|
||||
>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!filesList.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='There are no files here'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
slot='file-container'
|
||||
className={cn(
|
||||
"w-full",
|
||||
layout === "list"
|
||||
? "flex flex-col gap-2"
|
||||
: "grid grid-cols-1 gap-2 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{filesList.map((file) => (
|
||||
<div
|
||||
slot='item'
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
<FileItem
|
||||
data={file}
|
||||
layout={layout}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{nextToken && (
|
||||
<LoadingButton
|
||||
className='w-full'
|
||||
loading={isLoadingMore}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
Load more files
|
||||
</LoadingButton>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
import { Schema_Breadcrumb } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function FilePath({ data }: Props) {
|
||||
const loading = useLoading();
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
if (loading) return <Skeleton className='my-2 h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='file-path'
|
||||
className='flex w-full flex-nowrap items-center'
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Root */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={"1rem"}
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{!!data.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{data.length >= config.siteConfig.breadcrumbMax && (
|
||||
<>
|
||||
<ResponsiveDropdown
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<div>
|
||||
<BreadcrumbLink asChild>
|
||||
<BreadcrumbEllipsis className='w-4 cursor-pointer' />
|
||||
</BreadcrumbLink>
|
||||
</div>
|
||||
}
|
||||
mobile={{
|
||||
title: "Navigate",
|
||||
description: "Navigate to parent directories",
|
||||
content: data.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => (
|
||||
<BreadcrumbItem key={`mobile-${item.label}/${item.href}`}>
|
||||
<BreadcrumbLink asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
desktop={{
|
||||
align: "start",
|
||||
items: data.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => ({
|
||||
type: "item",
|
||||
key: `desktop-${item.label}/${item.href}`,
|
||||
title: item.label,
|
||||
onClick: () => {
|
||||
router.push(
|
||||
`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`,
|
||||
);
|
||||
},
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${data
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Markdown } from "~/components/Global";
|
||||
import { Markdown } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
title: string;
|
||||
};
|
||||
export default function Readme({ content, title }: Props) {
|
||||
export default function FileReadme({ content, title }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
@@ -25,11 +24,11 @@ export default function Readme({ content, title }: Props) {
|
||||
<CardHeader className='pb-0'>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 overflow-hidden",
|
||||
"flex flex-col gap-4 overflow-hidden",
|
||||
"mobile:flex-row mobile:items-center mobile:justify-between",
|
||||
)}
|
||||
>
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{title}</h3>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
@@ -49,7 +48,6 @@ export default function Readme({ content, title }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Markdown
|
||||
@@ -1,270 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DrawerClose } from "~/components/ui/drawer";
|
||||
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileGrid({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
|
||||
toast.success("Link copied!", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Preparing download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
<ResponsiveDropdown
|
||||
slot='actions'
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "copy",
|
||||
title: "Copy link",
|
||||
icon: "Link",
|
||||
onClick: onCopy,
|
||||
disabled: disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "download",
|
||||
title: "Download",
|
||||
icon: "Download",
|
||||
onClick: onDownload,
|
||||
disabled: data.mimeType.includes("folder") || disabled,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={{
|
||||
title: "Actions",
|
||||
description: "What would you like to do with this file?",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onDownload}
|
||||
disabled={disabled || data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
slot='data'
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"h-full w-full rounded-[var(--radius)]",
|
||||
"border border-border transition",
|
||||
"flex flex-col content-stretch justify-stretch gap-3",
|
||||
"hover:bg-muted/50",
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex flex-shrink flex-grow flex-col items-center gap-3", "relative")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
|
||||
/>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"2rem"}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-xs text-foreground'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis || 0)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension && !data.mimeType.includes("folder") ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Grid, List } from "~/components/Explorer";
|
||||
import { ButtonLoading, Icon, Loader, Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
|
||||
import { ButtonState, Schema_File } from "~/types/schema";
|
||||
|
||||
import { GetFiles } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
encryptedId: string;
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
root?: boolean;
|
||||
};
|
||||
export default function Explorer({ encryptedId, files, nextPageToken, root }: Props) {
|
||||
const { layout } = useContext(LayoutContext);
|
||||
const loading = useLoading();
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname.split("/").slice(0, -1).join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [fileList, setFileList] = useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [loadMoreState, setLoadMoreState] = useState<ButtonState>("idle");
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadMoreState("loading");
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const id = await decryptData(encryptedId);
|
||||
const data = await GetFiles({ id, pageToken: nextToken });
|
||||
const uniqueData = [...fileList, ...data.files].filter(
|
||||
(item, index, array) => index === array.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
|
||||
setFileList(uniqueData);
|
||||
setNextToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadMoreState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loader message='Loading files...' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='explorer'
|
||||
className={cn("mx-auto w-full max-w-screen-desktop", "flex flex-grow-0 flex-col gap-3 p-3")}
|
||||
>
|
||||
{!root && (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
className='justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={"1rem"}
|
||||
className='mr-3'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!fileList.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='There are no files here'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
slot='file-container'
|
||||
className={cn(
|
||||
"w-full",
|
||||
layout === "list"
|
||||
? "flex flex-col gap-1.5"
|
||||
: "grid grid-cols-1 gap-3 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
slot='item'
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
{layout === "list" ? <List data={file} /> : <Grid data={file} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextToken && (
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
state={loadMoreState}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
Load more files
|
||||
</ButtonLoading>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DrawerClose } from "~/components/ui/drawer";
|
||||
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileList({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
|
||||
toast.success("Link copied!", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Preparing download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-row-reverse items-center'>
|
||||
<ResponsiveDropdown
|
||||
slot='actions'
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "copy",
|
||||
title: "Copy link",
|
||||
icon: "Link",
|
||||
onClick: onCopy,
|
||||
disabled: disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "download",
|
||||
title: "Download",
|
||||
icon: "Download",
|
||||
onClick: onDownload,
|
||||
disabled: data.mimeType.includes("folder") || disabled,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={{
|
||||
title: "Actions",
|
||||
description: "What would you like to do with this file?",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onDownload}
|
||||
disabled={disabled || data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
slot='data'
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"relative rounded-[var(--radius)] transition",
|
||||
"px-1.5 py-1 pr-10",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-[10px] text-foreground tablet:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis || 0)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex flex-grow flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension && !data.mimeType.includes("folder") ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as Actions } from "./Actions";
|
||||
export { default as FilePath } from "./FilePath";
|
||||
export { default as Grid } from "./Grid";
|
||||
export { default as Explorer } from "./Layout";
|
||||
export { default as List } from "./List";
|
||||
export { default as Readme } from "./Readme";
|
||||
export { default as FileActions } from "./FileActions";
|
||||
export { default as FileBreadcrumb } from "./FileBreadcrumbs";
|
||||
export { default as FileItem } from "./FileItem";
|
||||
export { default as FileExplorerLayout } from "./FileLayout";
|
||||
export { default as FileReadme } from "./FileReadme";
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Button, ButtonProps } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { ButtonState } from "~/types/schema";
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LucideProps, icons } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
type Props = {
|
||||
name: keyof typeof icons;
|
||||
wrapper?: ReactElement<"div">;
|
||||
} & LucideProps;
|
||||
|
||||
export default function Icon({ name, wrapper, ...props }: Props) {
|
||||
const LucideIcon = icons[name];
|
||||
|
||||
return (
|
||||
<div {...wrapper}>
|
||||
<LucideIcon {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
@@ -11,11 +10,13 @@ import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import "~/app/highlight.css";
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import "~/styles/code-highlight.css";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
@@ -23,7 +24,7 @@ type Props = {
|
||||
className?: string;
|
||||
};
|
||||
export default function Markdown({ content, view, className }: Props) {
|
||||
const [viewState, setViewState] = useState<"markdown" | "raw">(view || "markdown");
|
||||
const [viewState, setViewState] = useState<"markdown" | "raw">(view ?? "markdown");
|
||||
|
||||
useEffect(() => {
|
||||
if (view) setViewState(view);
|
||||
@@ -35,13 +36,11 @@ export default function Markdown({ content, view, className }: Props) {
|
||||
className={cn("flex flex-col p-3", className)}
|
||||
>
|
||||
{viewState === "raw" ? (
|
||||
<div className={cn("markdown w-full rounded-[var(--radius)]")}>
|
||||
<pre className='w-full whitespace-pre-wrap break-words bg-transparent !p-0 font-sans shadow shadow-background'>
|
||||
{content}
|
||||
</pre>
|
||||
<div className={cn("markdown w-full rounded-lg bg-background")}>
|
||||
<pre className='w-full whitespace-pre-wrap break-words bg-transparent p-3 text-sm'>{content}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("markdown w-full rounded-[var(--radius)]")}>
|
||||
<div className={cn("markdown w-full rounded-lg")}>
|
||||
<ReactMarkdown
|
||||
className='w-full'
|
||||
disallowedElements={["script"]}
|
||||
|
||||
@@ -2,28 +2,29 @@
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
icon: keyof typeof icons;
|
||||
iconClassName?: string;
|
||||
message?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
export default function Status({ icon, message, destructive }: Props) {
|
||||
export default function Status({ icon, iconClassName, message, destructive }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
destructive ? "text-destructive" : "text-foreground",
|
||||
"h-fit w-full",
|
||||
"flex flex-col items-center justify-center gap-2",
|
||||
destructive ? "stroke-destructive text-destructive" : "stroke-muted-foreground text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={icon}
|
||||
size={"1.5rem"}
|
||||
className={cn("size-10", iconClassName)}
|
||||
/>
|
||||
<p>{message || "Something went wrong"}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { default as Icon } from "../ui/icon";
|
||||
export { default as ButtonLoading } from "./ButtonLoading";
|
||||
export { default as Icon } from "./Icon";
|
||||
export { default as Loader } from "./Loader";
|
||||
export { default as Markdown } from "./Markdown";
|
||||
export { default as Status } from "./Status";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
@@ -138,7 +138,7 @@ Default: 5`,
|
||||
inputKey: "proxyThumbnail",
|
||||
title: "Allow Proxy Thumbnail",
|
||||
description: `Use API route instead of Google Drive thumbnail link
|
||||
|
||||
|
||||
If your files thumbnail are not accessible, you can enable this option
|
||||
This will fetch the thumbnail image via API route`,
|
||||
required: true,
|
||||
@@ -157,7 +157,7 @@ This will fetch the thumbnail image via API route`,
|
||||
inputKey: "allowDownloadProtectedFile",
|
||||
title: "Allow Download Protected File",
|
||||
description: `Allow users to download files inside the protected folder
|
||||
|
||||
|
||||
If set to true, users will be able to download the file without entering the password as long as they have the direct link
|
||||
If set to false, users will need to enter the password before downloading the file, even if they have the direct link`,
|
||||
required: true,
|
||||
@@ -317,7 +317,7 @@ Default: 100`,
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
className='flex w-full flex-col gap-4 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader
|
||||
@@ -328,7 +328,7 @@ Default: 100`,
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
className='flex flex-col gap-4'
|
||||
>
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"api", ConfigurationKeys<"api">>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
@@ -52,7 +52,7 @@ export default function ConfigEnvironment({ state: { get, set }, error, onReset
|
||||
required: true,
|
||||
action: {
|
||||
label: "Generate",
|
||||
state: encryptionState,
|
||||
loading: encryptionState,
|
||||
icon: "RefreshCw",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
@@ -99,7 +99,7 @@ To avoid error when input, please use the "Load JSON" button to load the file.`,
|
||||
readOnly: true,
|
||||
action: {
|
||||
label: "Load JSON",
|
||||
state: serviceAuthState,
|
||||
loading: serviceAuthState,
|
||||
icon: "Upload",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
@@ -160,7 +160,7 @@ Only required if the site is set to private`,
|
||||
e.preventDefault();
|
||||
setIsRevealPassword((prev) => !prev);
|
||||
},
|
||||
state: "idle",
|
||||
loading: "idle",
|
||||
},
|
||||
value: get.environment.SITE_PASSWORD,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
@@ -207,7 +207,7 @@ Only required if the site is set to private`,
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
className='flex w-full flex-col gap-4 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader
|
||||
@@ -218,7 +218,7 @@ Only required if the site is set to private`,
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
className='flex flex-col gap-4'
|
||||
>
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"environment", ConfigurationKeys<"environment">>
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { ButtonState } from "~/types/schema";
|
||||
import { Button, LoadingButton } from "~/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
onLoad?: () => void;
|
||||
};
|
||||
export default function ConfigurationHeader({ title, onLoad }: Props) {
|
||||
const [loadState, setLoadState] = useState<ButtonState>("idle");
|
||||
const [isLoading, setLoading] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='header'
|
||||
className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
className='flex flex-col gap-4 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<div
|
||||
slot='actions'
|
||||
className='flex w-full items-center gap-3 tablet:w-fit'
|
||||
className='flex w-full items-center gap-4 tablet:w-fit'
|
||||
>
|
||||
{onLoad && (
|
||||
<ButtonLoading
|
||||
<LoadingButton
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
state={loadState}
|
||||
loading={isLoading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setLoadState("loading");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
onLoad();
|
||||
@@ -42,12 +39,12 @@ export default function ConfigurationHeader({ title, onLoad }: Props) {
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadState("idle");
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load File
|
||||
</ButtonLoading>
|
||||
</LoadingButton>
|
||||
)}
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { ButtonLoading, Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { LoadingButton } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { ButtonState, ConfigurationCategory, ConfigurationKeys, ConfigurationValue } from "~/types/schema";
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue } from "~/types/schema";
|
||||
|
||||
export type ConfigInputs<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
@@ -30,7 +31,7 @@ type ConfigInputProps<
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
state: ButtonState;
|
||||
loading: boolean;
|
||||
icon?: keyof typeof icons;
|
||||
};
|
||||
value: ConfigurationValue<T, K>;
|
||||
@@ -82,7 +83,7 @@ export default function ConfigurationInput<
|
||||
{props.type === "group" && (
|
||||
<div
|
||||
slot='group'
|
||||
className='grid w-full gap-1.5'
|
||||
className='grid w-full gap-2'
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
}}
|
||||
@@ -113,14 +114,14 @@ function Inputs<
|
||||
<div
|
||||
id={key}
|
||||
slot={`input-${key}`}
|
||||
className='flex w-full flex-col gap-1.5'
|
||||
className='flex w-full flex-col gap-2'
|
||||
style={{
|
||||
gridColumn: `span ${props.columnSpan || 1} / span ${props.columnSpan || 1}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-1.5'
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
@@ -152,7 +153,7 @@ function Inputs<
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='grid w-full grid-cols-6 gap-1.5'
|
||||
className='grid w-full grid-cols-6 gap-2'
|
||||
>
|
||||
<div className={cn("w-full", props.action ? "col-span-5" : "col-span-full")}>
|
||||
{props.type === "text" || props.type === "password" || props.type === "number" ? (
|
||||
@@ -211,14 +212,14 @@ function Inputs<
|
||||
)}
|
||||
</div>
|
||||
{props.action && (
|
||||
<ButtonLoading
|
||||
<LoadingButton
|
||||
variant={"secondary"}
|
||||
onClick={props.action.onClick}
|
||||
state={props.action.state}
|
||||
icon={props.action.icon}
|
||||
loading={props.action.loading}
|
||||
>
|
||||
{props.action.icon && <Icon name={props.action.icon} />}
|
||||
{props.action.label}
|
||||
</ButtonLoading>
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,26 +2,21 @@
|
||||
|
||||
import { AsyncZippable, strToU8, zip } from "fflate";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { generateConfig } from "~/data/template";
|
||||
|
||||
import { ButtonLoading, Loader, Markdown } from "~/components/Global";
|
||||
import { ConfigAPI, ConfigEnvironment, ConfigSite } from "~/components/Guide/Configuration";
|
||||
import { Icon, Markdown } from "~/components/global";
|
||||
import { PageLoader as Loader } from "~/components/layout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Button, LoadingButton } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { downloadBlob } from "~/utils/downloadBlob";
|
||||
|
||||
import {
|
||||
ButtonState,
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
} from "~/types/schema";
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue, Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
@@ -81,7 +76,7 @@ export default function ConfigurationForm() {
|
||||
site: {},
|
||||
});
|
||||
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
const [isDownloading, setDownloading] = useState<boolean>(false);
|
||||
|
||||
function onConfigurationChange<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
@@ -129,7 +124,7 @@ export default function ConfigurationForm() {
|
||||
}
|
||||
async function onDownload(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
setDownloadState("loading");
|
||||
setDownloading(true);
|
||||
toast.loading("Generating configuration file...", {
|
||||
id: "generate-config",
|
||||
});
|
||||
@@ -201,7 +196,7 @@ export default function ConfigurationForm() {
|
||||
id: "generate-config",
|
||||
});
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +212,7 @@ export default function ConfigurationForm() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex items-end justify-between gap-3'>
|
||||
<div className='flex items-end justify-between gap-4'>
|
||||
<CardTitle id='config'>App Configuration</CardTitle>
|
||||
<small className='text-muted-foreground'>v{config.version}</small>
|
||||
</div>
|
||||
@@ -276,7 +271,7 @@ If you're migrating from previous version, you can load your old environment and
|
||||
<AlertTitle>
|
||||
<h4>Configuration Issue</h4>
|
||||
</AlertTitle>
|
||||
<AlertDescription className='flex flex-col gap-1.5'>
|
||||
<AlertDescription className='flex flex-col gap-2'>
|
||||
<p className='w-full whitespace-pre-wrap text-pretty py-1.5'>{`After some report, it seems that the config will generate incorrect encrypted ID, and somehow it only happen on deployment.
|
||||
I'm sorry for the inconvenience, but you need to re-encrypt the id using the encryption endpoint.
|
||||
I'll try to fix this issue as soon as possible`}</p>
|
||||
@@ -288,7 +283,7 @@ I'll try to fix this issue as soon as possible`}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex w-full flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-end'>
|
||||
<div className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-center tablet:justify-end'>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
@@ -299,14 +294,14 @@ I'll try to fix this issue as soon as possible`}</p>
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
<ButtonLoading
|
||||
state={downloadState}
|
||||
<LoadingButton
|
||||
loading={isDownloading}
|
||||
size={"sm"}
|
||||
icon={"Save"}
|
||||
onClick={onDownload}
|
||||
>
|
||||
<Icon name='Save' />
|
||||
Download Config
|
||||
</ButtonLoading>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue, Schema_App_Configuration } from "~/types/schema";
|
||||
@@ -44,7 +44,7 @@ export default function ConfigSite({ state: { get, set }, error, onReset }: Prop
|
||||
inputKey: "siteNameTemplate",
|
||||
title: "Site Name Template",
|
||||
description: `The template used to generate the site name
|
||||
|
||||
|
||||
Available variables:
|
||||
%s - Page title
|
||||
%t - Site name`,
|
||||
@@ -141,7 +141,7 @@ e.g. "file.mp4" instead of "file"`,
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
className='flex w-full flex-col gap-4 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader title='Site Configuration' />
|
||||
@@ -149,10 +149,10 @@ e.g. "file.mp4" instead of "file"`,
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
className='flex flex-col gap-4'
|
||||
>
|
||||
<div className='grid grid-cols-3 gap-3'>
|
||||
<div className='col-span-2 flex flex-col gap-3'>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='col-span-2 flex flex-col gap-4'>
|
||||
{metadataInputs.map((props, index) => (
|
||||
<ConfigurationInput<"site", ConfigurationKeys<"site">>
|
||||
key={String(props.inputKey)}
|
||||
@@ -160,10 +160,10 @@ e.g. "file.mp4" instead of "file"`,
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-col items-start gap-1.5 pt-3'>
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<div className='flex flex-col items-start gap-2 pt-3'>
|
||||
<div className='flex select-none items-center gap-2 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={get.site.favIcon}
|
||||
className='size-4'
|
||||
@@ -177,7 +177,7 @@ e.g. "file.mp4" instead of "file"`,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<div className='flex select-none items-center gap-2 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={get.site.favIcon}
|
||||
className='size-4'
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function ThemeFormInputs({ currentTheme, state: { get, set } }: P
|
||||
value: { h: 0, s: 0, l: 0 },
|
||||
onChange: (val) => {},
|
||||
children: (
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<div className='flex w-full items-center gap-4'>
|
||||
<Slider
|
||||
className='w-full'
|
||||
defaultValue={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
@@ -179,10 +179,10 @@ export default function ThemeFormInputs({ currentTheme, state: { get, set } }: P
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='flex h-auto flex-grow flex-col justify-between gap-3'>
|
||||
<div className='flex h-auto flex-grow flex-col justify-between gap-4'>
|
||||
<div
|
||||
slot='theme-form'
|
||||
className='flex flex-col gap-1.5'
|
||||
className='flex flex-col gap-2'
|
||||
>
|
||||
{inputs.map((item, i) => (
|
||||
<Fragment key={`input-group-${i}`}>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import { HslColor, HslColorPicker } from "react-colorful";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ButtonLoading, Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent } from "~/components/ui/popover";
|
||||
@@ -30,7 +31,7 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-3'
|
||||
className='flex items-center gap-4'
|
||||
>
|
||||
<span className='text-base'>{props.title}</span>
|
||||
{props.description && (
|
||||
@@ -57,7 +58,7 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='col-span-2 flex flex-grow items-center justify-end gap-3'
|
||||
className='col-span-2 flex flex-grow items-center justify-end gap-4'
|
||||
>
|
||||
{props.children ? (
|
||||
props.children
|
||||
@@ -77,14 +78,14 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='flex flex-col gap-3'>
|
||||
<PopoverContent className='flex flex-col gap-4'>
|
||||
<div className='flex gap-6'>
|
||||
<HslColorPicker
|
||||
color={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<div className='flex flex-grow flex-col items-center justify-between'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor={`${props.inputKey}-h`}>Hue</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-h`}
|
||||
@@ -103,7 +104,7 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor={`${props.inputKey}-s`}>Saturation</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-s`}
|
||||
@@ -122,7 +123,7 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor={`${props.inputKey}-l`}>Lightness</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-l`}
|
||||
@@ -143,12 +144,11 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<ButtonLoading
|
||||
<div className='flex w-full items-center gap-4'>
|
||||
<Button
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
state='idle'
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -163,12 +163,11 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ButtonLoading>
|
||||
<ButtonLoading
|
||||
</Button>
|
||||
<Button
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
state='idle'
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -195,7 +194,7 @@ export default function ThemeInput(props: ThemeInputProps) {
|
||||
}}
|
||||
>
|
||||
Paste
|
||||
</ButtonLoading>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { generateTheme } from "~/data/template";
|
||||
|
||||
import { ButtonLoading, Icon, Loader, Markdown } from "~/components/Global";
|
||||
import { FormInputs, ThemePreview } from "~/components/Guide/Theme";
|
||||
import { ButtonLoading, Icon, Loader, Markdown } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
@@ -218,11 +218,11 @@ export default function ThemeForm() {
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<CardTitle id='theme'>Theme Customization</CardTitle>
|
||||
<div
|
||||
slot='actions'
|
||||
className='flex w-full items-center gap-3 tablet:w-fit'
|
||||
className='flex w-full items-center gap-4 tablet:w-fit'
|
||||
>
|
||||
<Dialog
|
||||
open={pasteDialog}
|
||||
@@ -360,7 +360,7 @@ export default function ThemeForm() {
|
||||
You can either use default colors from [shadcn/ui](https://ui.shadcn.com/themes) or create your own theme by changing the color code of each element.`}
|
||||
/>
|
||||
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<div className='flex w-full items-center justify-between gap-4'>
|
||||
<div className='flex items-center'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
@@ -390,7 +390,7 @@ You can either use default colors from [shadcn/ui](https://ui.shadcn.com/themes)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
state={downloadState}
|
||||
@@ -410,7 +410,7 @@ You can either use default colors from [shadcn/ui](https://ui.shadcn.com/themes)
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-3 rounded-[var(--radius)] border border-border p-4 tablet:grid-cols-2'>
|
||||
<div className='grid grid-cols-1 gap-4 rounded-[var(--radius)] border border-border p-4 tablet:grid-cols-2'>
|
||||
<FormInputs
|
||||
currentTheme={currentTheme}
|
||||
state={{
|
||||
|
||||
@@ -4,11 +4,11 @@ import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Grid as FileGrid, List as FileList } from "~/components/Explorer";
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { ButtonLoading } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import {
|
||||
@@ -31,8 +31,6 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTr
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState, Schema_File, Schema_Theme } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
@@ -62,7 +60,7 @@ export default function ThemePreview({ theme }: Props) {
|
||||
<div
|
||||
slot='preview'
|
||||
ref={setPortalContainer}
|
||||
className='preview flex flex-col gap-3 bg-background p-3 text-foreground'
|
||||
className='preview flex flex-col gap-4 bg-background p-3 text-foreground'
|
||||
style={
|
||||
{
|
||||
"--background": theme.background,
|
||||
@@ -91,7 +89,7 @@ export default function ThemePreview({ theme }: Props) {
|
||||
<Wrapper
|
||||
title='Button'
|
||||
extra={`Primary, Secondary, Accent, Destructive, Input Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
className='grid grid-cols-3 gap-2'
|
||||
>
|
||||
{["default", "secondary", "destructive", "outline", "ghost", "link"].map((btn) => (
|
||||
<Button
|
||||
@@ -118,7 +116,7 @@ export default function ThemePreview({ theme }: Props) {
|
||||
<Wrapper
|
||||
title='Popover'
|
||||
extra={`Popover, Element Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
className='grid grid-cols-3 gap-2'
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -171,7 +169,7 @@ export default function ThemePreview({ theme }: Props) {
|
||||
<Wrapper
|
||||
title='Overlay'
|
||||
extra={`Background, Primary, Muted, Element Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
className='grid grid-cols-3 gap-2'
|
||||
>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
@@ -270,16 +268,16 @@ export default function ThemePreview({ theme }: Props) {
|
||||
|
||||
<Wrapper
|
||||
title='File'
|
||||
className='grid grid-cols-1 gap-1.5'
|
||||
className='grid grid-cols-1 gap-2'
|
||||
>
|
||||
<FileList
|
||||
{/* <FileList
|
||||
data={fileMockData}
|
||||
disabled
|
||||
/>
|
||||
<FileGrid
|
||||
data={fileMockData}
|
||||
disabled
|
||||
/>
|
||||
/> */}
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +291,7 @@ type WrapperProps = {
|
||||
};
|
||||
function Wrapper({ title, extra, className, children }: React.PropsWithChildren<WrapperProps>) {
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<div className='flex w-full flex-col gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<h3>{title}</h3>
|
||||
{extra && <p className='text-xs opacity-50'>{extra}</p>}
|
||||
|
||||
@@ -1,50 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
|
||||
import { isDev } from "~/utils/isDev";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
content?: string;
|
||||
};
|
||||
export default function Footer({ content }: Props) {
|
||||
return (
|
||||
<footer className='flex w-full items-center justify-center pb-3'>
|
||||
<ReactMarkdown
|
||||
className='flex w-full select-none flex-col items-center justify-center text-center'
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='muted text-balance text-sm'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ node, children, ...props }) => {
|
||||
const isExternal = props.href?.startsWith("http");
|
||||
const pathname = usePathname();
|
||||
const loading = useLoading();
|
||||
const [loadTime, setLoadTime] = useState<number>(0);
|
||||
|
||||
return (
|
||||
<a
|
||||
return (
|
||||
<footer className='flex w-full flex-col items-center justify-center pb-3'>
|
||||
{config.siteConfig.experimental_pageLoadTime &&
|
||||
(loading ? (
|
||||
<Skeleton className='h-4 w-40 max-w-96' />
|
||||
) : (
|
||||
<span className='text-center text-sm text-muted-foreground'>
|
||||
Page loaded in{" "}
|
||||
{config.siteConfig.experimental_pageLoadTime === "ms"
|
||||
? `${Math.round(loadTime ?? 0)}`
|
||||
: `${loadTime?.toFixed(2)}`}
|
||||
{config.siteConfig.experimental_pageLoadTime}
|
||||
</span>
|
||||
))}
|
||||
{content && (
|
||||
<ReactMarkdown
|
||||
className='flex w-full select-none flex-col items-center justify-center text-center'
|
||||
components={{
|
||||
p: ({ children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
className='muted text-balance text-sm'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
{isDev && (
|
||||
<div className='fixed bottom-0 z-[999] rounded-t-[var(--radius)] border border-b-0 border-border bg-primary px-3 py-1 text-xs text-primary-foreground'>
|
||||
Dev Mode
|
||||
</div>
|
||||
</p>
|
||||
),
|
||||
a: ({ children, ...props }) => {
|
||||
const isExternal = props.href?.startsWith("http");
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
|
||||
+393
-464
@@ -2,21 +2,12 @@
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -34,20 +25,25 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetFooter, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useConfirmDialog } from "~/context/confirmProvider";
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { ClearSavedPasswords } from "~/actions/password";
|
||||
|
||||
import { ClearPassword } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const { theme, themes, setTheme } = useTheme();
|
||||
const { isDesktop } = useResponsive();
|
||||
const isLoading = useLoading();
|
||||
const confirm = useConfirmDialog();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [themeOpen, setThemeOpen] = useState<boolean>(false);
|
||||
@@ -57,20 +53,13 @@ export default function Navbar() {
|
||||
}, [isDesktop]);
|
||||
|
||||
async function onClearPassword() {
|
||||
const promise = new Promise(async (resolve, reject) => {
|
||||
const clear = await ClearPassword();
|
||||
if (!clear.success) reject(new Error(clear.message));
|
||||
resolve(clear.message);
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Clearing all saved password...",
|
||||
success: () => {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
return "All saved password cleared successfully!";
|
||||
},
|
||||
error: (error) => error.message,
|
||||
toast.loading("Clearing all saved password...", {
|
||||
id: "clear-password",
|
||||
});
|
||||
|
||||
const clear = await ClearSavedPasswords();
|
||||
if (!clear.success) return toast.error(clear.error, { id: "clear-password" });
|
||||
toast.success(clear.message, { id: "clear-password" });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -78,9 +67,9 @@ export default function Navbar() {
|
||||
<nav
|
||||
slot='nav'
|
||||
className={cn(
|
||||
"bg-background",
|
||||
"bg-card shadow-md",
|
||||
"h-12 w-full",
|
||||
"px-6 py-3",
|
||||
"px-2 py-2 md:px-4",
|
||||
"flex flex-grow flex-row items-center justify-between",
|
||||
"border-b border-border",
|
||||
)}
|
||||
@@ -89,7 +78,7 @@ export default function Navbar() {
|
||||
<Link
|
||||
href={"/"}
|
||||
onClick={() => setOpen(false)}
|
||||
className='flex items-center gap-3'
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<img
|
||||
src={config.siteConfig.siteIcon}
|
||||
@@ -100,394 +89,94 @@ export default function Navbar() {
|
||||
<span className={cn("large", "hidden", "tablet:block")}>{config.siteConfig.siteName}</span>
|
||||
</Link>
|
||||
|
||||
{isDesktop ? (
|
||||
<div className='flex'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
pathname === item.href ? "cursor-default opacity-100" : "cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>{item.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-3 my-auto h-6'
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={theme === "system" ? "LaptopMinimal" : theme === "light" ? "Sun" : "Moon"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Site theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='start'>
|
||||
{themes.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
disabled={item === theme}
|
||||
className='w-full'
|
||||
onClick={() => setTheme(item)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='center'>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<DropdownMenuItem key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<span className='muted'>{item.currency}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{config.showDeployGuide && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={"/deploy"}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
<span className='text-sm'>Guide</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Deploy Guide</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-3 my-auto h-6'
|
||||
/>
|
||||
|
||||
<Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LogOut'
|
||||
className='text-red-500'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Clear all saved password</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You will need to re-enter the password to access the protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-8 w-1/3' />
|
||||
) : (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Drawer
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name={theme === "system" ? "LaptopMinimal" : theme === "light" ? "Sun" : "Moon"}
|
||||
size={"1.125rem"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Theme</DrawerTitle>
|
||||
<DrawerDescription>Choose your preferred theme</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={theme === item ? "default" : "outline"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
size={"1.125rem"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col items-center gap-3 pt-12'>
|
||||
<div className='flex w-full flex-grow flex-col gap-6 overflow-y-auto'>
|
||||
<div className='flex flex-col'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<>
|
||||
{isDesktop ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.external && (
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
pathname === item.href ? "cursor-default opacity-100" : "cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{config.showDeployGuide && (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={"/deploy"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
className='text-foreground'
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
Deploy Guide
|
||||
</div>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>{item.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<div className='hidden flex-col'>
|
||||
<p className='large'>Theme</p>
|
||||
<Separator className='my-1.5' />
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<span
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-2 my-auto h-6'
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={"SunMoon"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Site theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{themes.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
disabled={item === theme}
|
||||
className='w-full'
|
||||
onClick={() => setTheme(item)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
@@ -498,84 +187,324 @@ export default function Navbar() {
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={"1.125rem"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<div className='flex flex-col'>
|
||||
<p className='large'>Support & Donation</p>
|
||||
<Separator className='my-1.5' />
|
||||
{config.siteConfig.supports.length && (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<DropdownMenuItem key={item.name}>
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
>
|
||||
<small>{item.name}</small>
|
||||
<small className='muted'>{item.currency}</small>
|
||||
<span>{item.name}</span>
|
||||
<span className='muted'>{item.currency}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-2 my-auto h-6'
|
||||
/>
|
||||
|
||||
{config.showDeployGuide && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant={"secondary"}
|
||||
size={"icon"}
|
||||
className='w-fit'
|
||||
>
|
||||
<Link
|
||||
href={"/deploy"}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
<span className='text-sm'>Deploy Guide</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Deploy Guide</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
size={"icon"}
|
||||
onClick={async () => {
|
||||
await confirm({
|
||||
title: "Clear all saved password?",
|
||||
description: "You will need to re-enter the password to access the protected content.",
|
||||
confirmText: "Clear all",
|
||||
confirmProps: {
|
||||
variant: "destructive",
|
||||
},
|
||||
cancelText: "Cancel",
|
||||
async onConfirm() {
|
||||
await onClearPassword();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon name='LogOut' />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Drawer
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name={"SunMoon"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Theme</DrawerTitle>
|
||||
<DrawerDescription>Choose your preferred theme</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4'>
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={theme === item ? "secondary" : "outline"}
|
||||
size={"default"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className='w-full'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Menu</DrawerTitle>
|
||||
<DrawerDescription>Explore the site</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.external && (
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{config.showDeployGuide && (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={"/deploy"}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
Deploy Guide
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Support</DrawerTitle>
|
||||
<DrawerDescription>Support the site</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4'>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
>
|
||||
<small>{item.name}</small>
|
||||
<small className='muted'>{item.currency}</small>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DrawerFooter className='w-full'>
|
||||
<Separator className='my-3' />
|
||||
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
className='w-full gap-3'
|
||||
size={"sm"}
|
||||
className='w-full gap-4'
|
||||
onClick={async () => {
|
||||
await confirm({
|
||||
title: "Clear all saved password?",
|
||||
description: "You will need to re-enter the password to access the protected content.",
|
||||
confirmText: "Clear all",
|
||||
confirmProps: {
|
||||
variant: "destructive",
|
||||
},
|
||||
cancelText: "Cancel",
|
||||
async onConfirm() {
|
||||
await onClearPassword();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Clear all saved password?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
You will need to re-enter the password to access the protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerFooter className='gap-1.5'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-12")}>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='The file you are looking for does not exist'
|
||||
/>
|
||||
|
||||
<div className={cn("w-full", "flex flex-col items-center gap-3")}>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Icon } from "~/components/global";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
extra?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function Loader({ message, extra }: Props) {
|
||||
export default function PageLoader({ message, extra }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
"flex flex-grow flex-col items-center justify-center gap-2",
|
||||
"text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"2rem"}
|
||||
className='animate-spin'
|
||||
className='size-10 animate-spin stroke-primary'
|
||||
/>
|
||||
<p>{message || "Loading..."}</p>
|
||||
{extra}
|
||||
<div className='flex animate-pulse flex-col'>
|
||||
<p>{message || "Loading..."}</p>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
import { CheckIndexPassword, CheckPagePassword, SetIndexPassword, SetPagePassword } from "~/actions/password";
|
||||
import { cn, toUrlPath } from "~/lib/utils";
|
||||
|
||||
import { Icon, Loader } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import { LoadingButton } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
import { CheckPassword, CheckSitePassword, SetPassword, SetSitePassword } from "actions";
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
checkPaths?: { path: string; id: string }[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
type Props =
|
||||
| {
|
||||
type: "global";
|
||||
errorMessage?: string;
|
||||
}
|
||||
| {
|
||||
type: "path";
|
||||
paths: { path: string; id: string }[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
export default function Password(props: Props) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const loading = useLoading();
|
||||
|
||||
@@ -27,48 +33,33 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!input) return toast.error("Password is required", { id: "password" });
|
||||
setSubmitLoading(true);
|
||||
toast.loading("Checking password...", {
|
||||
id: "password",
|
||||
});
|
||||
toast.loading("Validating password...", { id: "password" });
|
||||
|
||||
try {
|
||||
if (!input) throw new Error("Password is required");
|
||||
if (path === "global") {
|
||||
const set = await SetSitePassword(input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
const check = await CheckSitePassword();
|
||||
if (!check.success) throw new Error(check.message);
|
||||
if (props.type === "global") {
|
||||
await SetIndexPassword(input);
|
||||
const res = await CheckIndexPassword();
|
||||
if (!res.success) return toast.error(res.error, { id: "password" });
|
||||
} else {
|
||||
if (!checkPaths) throw new Error("No path found, try to refresh the page.");
|
||||
const set = await SetPassword(path, input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
const check = await CheckPassword(checkPaths);
|
||||
if (!check.success) throw new Error(check.message);
|
||||
await SetPagePassword(toUrlPath(props.paths), input);
|
||||
const res = await CheckPagePassword(props.paths);
|
||||
if (!res.success) return toast.error(res.error, { id: "password" });
|
||||
}
|
||||
|
||||
toast.success("Password accepted! Refreshing...", {
|
||||
id: "password",
|
||||
});
|
||||
toast.success("Password accepted! Refreshing...", { id: "password" });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: "password",
|
||||
});
|
||||
setSubmitLoading(false);
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
setInput("");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loader message='Checking password...' />;
|
||||
if (loading) return <PageLoader message='Checking password...' />;
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-3")}>
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-4")}>
|
||||
<img
|
||||
src='/images/undraw_vault.svg'
|
||||
alt='Password illustration'
|
||||
@@ -77,14 +68,14 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<h3 className='text-balance text-center'>
|
||||
{path === "global"
|
||||
? "This site are password protected"
|
||||
{props.type === "global"
|
||||
? "The site is password protected"
|
||||
: "The folder or file you are trying to access is password protected"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={cn("w-full", "grid grid-cols-1 gap-3", "tablet:grid-cols-4")}
|
||||
className={cn("w-full", "grid grid-cols-1 gap-2", "tablet:grid-cols-4")}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
@@ -95,27 +86,16 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className='gap-3'
|
||||
<LoadingButton
|
||||
loading={submitLoading}
|
||||
type='submit'
|
||||
disabled={submitLoading}
|
||||
disabled={!input}
|
||||
>
|
||||
{submitLoading ? (
|
||||
<>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"1rem"}
|
||||
className='animate-spin'
|
||||
/>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>Submit</>
|
||||
)}
|
||||
</Button>
|
||||
Submit
|
||||
</LoadingButton>
|
||||
</form>
|
||||
<span className={cn("text-pretty text-center text-destructive", errorMessage ? "visible" : "invisible")}>
|
||||
{errorMessage || "Nothing wrong here, just a password wall"}
|
||||
<span className={cn("text-pretty text-center text-destructive", props.errorMessage ? "visible" : "invisible")}>
|
||||
{props.errorMessage || "Nothing here, just a password wall"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { type TooltipProviderProps } from "@radix-ui/react-tooltip";
|
||||
import { ThemeProvider, type ThemeProviderProps } from "next-themes";
|
||||
import NextTopLoader, { type NextTopLoaderProps } from "nextjs-toploader";
|
||||
import { useEffect } from "react";
|
||||
import { type ToasterProps } from "sonner";
|
||||
|
||||
import UseConfirmDialogProvider from "~/context/confirmProvider";
|
||||
import { LayoutProvider } from "~/context/layoutContext";
|
||||
import { ResponsiveProvider } from "~/context/responsiveContext";
|
||||
|
||||
import { Toaster } from "../ui/sonner";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
loader?: NextTopLoaderProps;
|
||||
theme?: ThemeProviderProps;
|
||||
tooltip?: Omit<TooltipProviderProps, "children">;
|
||||
toaster?: ToasterProps;
|
||||
};
|
||||
export default function Provider(props: React.PropsWithChildren<Props>) {
|
||||
useEffect(() => {
|
||||
const anchors = document.querySelectorAll("a");
|
||||
console.log(anchors);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<ResponsiveProvider>
|
||||
<NextTopLoader
|
||||
color='hsl(var(--primary))'
|
||||
{...props.loader}
|
||||
/>
|
||||
<ThemeProvider {...props.theme}>
|
||||
<UseConfirmDialogProvider>
|
||||
<TooltipProvider {...props.tooltip}>
|
||||
<div
|
||||
vaul-drawer-wrapper=''
|
||||
className='flex h-full min-h-screen w-full flex-col items-start bg-background font-sans text-foreground'
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Toaster {...props.toaster} />
|
||||
</UseConfirmDialogProvider>
|
||||
</ThemeProvider>
|
||||
</ResponsiveProvider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
import { HTMLProps } from "react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
@@ -195,7 +193,7 @@ export default function ResponsiveDropdown({ open, onOpenChange, trigger, mobile
|
||||
</DrawerHeader>
|
||||
)}
|
||||
|
||||
<div className='grid gap-1.5 px-4'>{mobile.content}</div>
|
||||
<div className='grid gap-2 px-4'>{mobile.content}</div>
|
||||
|
||||
{(mobile.footer || mobile.closeOnFooter) && (
|
||||
<DrawerFooter>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||
|
||||
import LayoutProvider from "~/context/layoutContext";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<NextTopLoader color='hsl(var(--primary))' />
|
||||
<NextThemeProvider {...props}>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div
|
||||
vaul-drawer-wrapper=''
|
||||
className={cn(
|
||||
"bg-background font-sans text-foreground",
|
||||
"h-full min-h-screen w-full",
|
||||
"flex flex-col items-start",
|
||||
// "tablet:flex-row",
|
||||
"pr-1.5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Toaster
|
||||
position={config.siteConfig.toaster?.position}
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
borderRadius: "var(--radius)",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
},
|
||||
duration: config.siteConfig.toaster?.duration,
|
||||
loading: {
|
||||
duration: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NextThemeProvider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Icon } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export default function ToTop() {
|
||||
const [show, setShow] = useState<boolean>(false);
|
||||
@@ -26,21 +26,19 @@ export default function ToTop() {
|
||||
<div
|
||||
slot='to-top-fab'
|
||||
className={cn(
|
||||
"fixed bottom-6 right-6 z-10 transition",
|
||||
"fixed bottom-6 right-6 z-50 transition",
|
||||
show ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
variant={"default"}
|
||||
className='h-12 w-12 rounded-full shadow'
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='ChevronUp'
|
||||
size='1rem'
|
||||
/>
|
||||
<Icon name='ChevronUp' />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { default as Footer } from "./Footer";
|
||||
export { default as Navbar } from "./Navbar";
|
||||
export { default as NotFound } from "./NotFound";
|
||||
export { default as PageLoader } from "./PageLoader";
|
||||
export { default as Password } from "./Password";
|
||||
export { default as ResponsiveDropdown } from "./ResponsiveDropdown";
|
||||
export { default as ThemeProvider } from "./ThemeProvider";
|
||||
export { default as Provider } from "./Provider";
|
||||
export { default as ToTop } from "./ToTop";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
import { Loader, Status } from "~/components/global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import DocViewer, { DocRenderer } from "@cyntler/react-doc-viewer";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Icon, Loader, Status } from "~/components/global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function PreviewDoc({ file }: Props) {
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Icon, Loader, Status } from "~/components/global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function PreviewImage({ file }: Props) {
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
|
||||
@@ -2,44 +2,59 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Drawer, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTrigger } from "~/components/ui/drawer";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
import { getFileType } from "~/lib/previewHelper";
|
||||
import { bytesToReadable, durationToReadable, formatDate } from "~/lib/utils";
|
||||
|
||||
import { ButtonState, Schema_File } from "~/types/schema";
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import { Button, LoadingButton } from "../ui/button";
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogBody,
|
||||
ResponsiveDialogClose,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogDescription,
|
||||
ResponsiveDialogFooter,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
} from "../ui/dialog.responsive";
|
||||
import {
|
||||
ResponsiveDropdownMenu,
|
||||
ResponsiveDropdownMenuContent,
|
||||
ResponsiveDropdownMenuItem,
|
||||
ResponsiveDropdownMenuSeparator,
|
||||
ResponsiveDropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu.responsive";
|
||||
import Icon from "../ui/icon";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewInformation({ file }: Props) {
|
||||
const pathname = usePathname();
|
||||
const fileType = useMemo<ReturnType<typeof getFileType>>(() => {
|
||||
return getFileType(file.fileExtension ?? "", file.mimeType) ?? "unknown";
|
||||
}, [file]);
|
||||
|
||||
const showRaw = useMemo<boolean>(() => {
|
||||
return file.mimeType.startsWith("image") || file.mimeType.startsWith("video") || file.mimeType.startsWith("audio");
|
||||
}, [file]);
|
||||
const showViewDoc = useMemo<boolean>(() => {
|
||||
const fileExtensionFallback = file.name.split(".").pop();
|
||||
const fileExt = file.fileExtension ?? fileExtensionFallback;
|
||||
if (!fileExt) return false;
|
||||
const fileType = getFileType(fileExt, file.mimeType);
|
||||
return fileType === "document" || fileType === "pdf";
|
||||
}, [file]);
|
||||
const showVirusTotal = useMemo<boolean>(
|
||||
() => fileType === "executable" || fileType === "manga" || fileType === "unknown",
|
||||
[fileType],
|
||||
);
|
||||
const showRawUrl = useMemo<boolean>(
|
||||
() => fileType === "video" || fileType === "audio" || fileType === "image",
|
||||
[fileType],
|
||||
);
|
||||
const showViewDoc = useMemo<boolean>(() => fileType === "document", [fileType]);
|
||||
|
||||
const fileInfo = useMemo<{ label: string; value: string }[]>(() => {
|
||||
const value = [
|
||||
@@ -53,23 +68,27 @@ export default function PreviewInformation({ file }: Props) {
|
||||
},
|
||||
{
|
||||
label: "Size",
|
||||
value: `${bytesToReadable(file.size || 0)} (${file.size || 0} bytes)`,
|
||||
value: `${bytesToReadable(file.size ?? 0)} (${file.size ?? 0} bytes)`,
|
||||
},
|
||||
{
|
||||
label: "Last Modified",
|
||||
value: file.modifiedTime,
|
||||
value: formatDate(file.modifiedTime),
|
||||
},
|
||||
];
|
||||
if (file.imageMediaMetadata) {
|
||||
value.push({
|
||||
label: "Dimension",
|
||||
value: `${file.imageMediaMetadata.width} x ${file.imageMediaMetadata.height}`,
|
||||
value: `${file.imageMediaMetadata.width}px x ${file.imageMediaMetadata.height}px (${
|
||||
Math.round((file.imageMediaMetadata.width / file.imageMediaMetadata.height) * 100) / 100
|
||||
})`,
|
||||
});
|
||||
}
|
||||
if (file.videoMediaMetadata) {
|
||||
value.push({
|
||||
label: "Dimension",
|
||||
value: `${file.videoMediaMetadata.width} x ${file.videoMediaMetadata.height}`,
|
||||
value: `${file.videoMediaMetadata.width}px x ${file.videoMediaMetadata.height}px (${
|
||||
Math.round((file.videoMediaMetadata.width / file.videoMediaMetadata.height) * 100) / 100
|
||||
})`,
|
||||
});
|
||||
value.push({
|
||||
label: "Duration",
|
||||
@@ -80,15 +99,20 @@ export default function PreviewInformation({ file }: Props) {
|
||||
return value;
|
||||
}, [file]);
|
||||
|
||||
const [viewerState, setViewerState] = useState<ButtonState>("idle");
|
||||
const [copyRawState, setCopyRawState] = useState<ButtonState>("idle");
|
||||
const [copyDownloadState, setCopyDownloadState] = useState<ButtonState>("idle");
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
const [viewerBtnLoading, setViewerBtnLoading] = useState<boolean>(false);
|
||||
const [copyBtnLoading, setCopyBtnLoading] = useState<boolean>(false);
|
||||
const [copyDownloadBtnLoading, setCopyDownloadBtnLoading] = useState<boolean>(false);
|
||||
const [downloadBtnLoading, setDownloadBtnLoading] = useState<boolean>(false);
|
||||
const [isRawExplanationOpen, setIsRawExplanationOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopyRawLink = useCallback(async () => {
|
||||
try {
|
||||
const rawUrl = new URL(`/api/raw?url=${encodeURIComponent(pathname)}`, config.basePath);
|
||||
} catch (error) {}
|
||||
}, []);
|
||||
|
||||
const onCopyRaw = async () => {
|
||||
setCopyRawState("loading");
|
||||
setCopyBtnLoading(true);
|
||||
try {
|
||||
const rawURL = new URL(`/api/raw/${pathname}`.replace(/\/+/g, "/"), config.basePath);
|
||||
rawURL.searchParams.append("token", file.encryptedId);
|
||||
@@ -101,12 +125,12 @@ export default function PreviewInformation({ file }: Props) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
} finally {
|
||||
setCopyRawState("idle");
|
||||
setCopyBtnLoading(false);
|
||||
}
|
||||
};
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCopyDownloadState("loading");
|
||||
setCopyDownloadBtnLoading(true);
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
@@ -126,12 +150,12 @@ export default function PreviewInformation({ file }: Props) {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setCopyDownloadState("idle");
|
||||
setCopyDownloadBtnLoading(false);
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDownloadState("loading");
|
||||
setDownloadBtnLoading(true);
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
@@ -153,7 +177,7 @@ export default function PreviewInformation({ file }: Props) {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
setDownloadBtnLoading(false);
|
||||
}
|
||||
};
|
||||
const onOpenViewer = async (e: React.MouseEvent) => {
|
||||
@@ -161,7 +185,7 @@ export default function PreviewInformation({ file }: Props) {
|
||||
toast.loading("Creating view token...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
setViewerState("loading");
|
||||
setViewerBtnLoading(true);
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create view token");
|
||||
@@ -185,179 +209,305 @@ export default function PreviewInformation({ file }: Props) {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setViewerState("idle");
|
||||
setViewerBtnLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
<Card slot='file-actions'>
|
||||
<div className='flex flex-col-reverse gap-3 p-6 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<div
|
||||
slot='raw-explanation'
|
||||
className={cn("flex gap-3", !showRaw && "invisible")}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
Difference between download link and raw link?
|
||||
</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className='flex h-fit flex-col gap-3'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
download link.
|
||||
</p>
|
||||
<p className='muted'>Note: This is only applicable for video, audio, and image files.</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
Difference between download link and raw link?
|
||||
</span>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader></DrawerHeader>
|
||||
<div className='flex h-fit flex-col gap-3 px-4'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
download link.
|
||||
</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<ResponsiveDialog
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Difference between download link and raw link?</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>Learn the reason why it's 2 different links.</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
<ResponsiveDialogBody>
|
||||
<p>
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
The <b>download link</b> only have encrypted file id in the URL, meanwhile <b>raw link</b> will have the
|
||||
whole path included in the URL.
|
||||
</p>
|
||||
<p>So it's recommended to use the raw link for embedding media file like video, audio, and image.</p>
|
||||
<span className='text-sm text-muted-foreground'>
|
||||
Note: This is only applicable for video, audio, and image files.
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col-reverse gap-3 tablet:flex-row tablet:items-center'>
|
||||
{showRaw ? (
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
icon='Copy'
|
||||
onClick={onCopyRaw}
|
||||
state={copyRawState}
|
||||
<Separator className='my-2' />
|
||||
|
||||
<p className={`text-sm`}>
|
||||
This information is based on the{" "}
|
||||
<code className='bg-muted px-1 text-xs text-muted-foreground'>onedrive-vercel-index</code> documentation.
|
||||
</p>
|
||||
<Link
|
||||
href={"https://ovi.swo.moe/docs/features/customise-direct-link"}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</ResponsiveDialogBody>
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</ResponsiveDialogClose>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='flex flex-col-reverse items-center gap-4 md:gap-2 tablet:flex-row tablet:justify-end'>
|
||||
<ResponsiveDropdownMenu>
|
||||
<ResponsiveDropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full tablet:w-fit'
|
||||
>
|
||||
Copy Link
|
||||
<Icon name='ChevronDown' />
|
||||
</Button>
|
||||
</ResponsiveDropdownMenuTrigger>
|
||||
|
||||
<ResponsiveDropdownMenuContent
|
||||
header={{
|
||||
title: "Copy Link",
|
||||
}}
|
||||
>
|
||||
Raw Link
|
||||
</ButtonLoading>
|
||||
) : null}
|
||||
{showViewDoc ? (
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
icon='ExternalLink'
|
||||
onClick={onOpenViewer}
|
||||
state={viewerState}
|
||||
<ResponsiveDropdownMenuItem closeOnSelect>Raw Link</ResponsiveDropdownMenuItem>
|
||||
<ResponsiveDropdownMenuItem closeOnSelect>Direct Download Link</ResponsiveDropdownMenuItem>
|
||||
|
||||
<ResponsiveDropdownMenuSeparator />
|
||||
|
||||
<ResponsiveDropdownMenuItem
|
||||
closeOnSelect
|
||||
onSelect={() => {
|
||||
setIsRawExplanationOpen(true);
|
||||
}}
|
||||
>
|
||||
Learn the difference
|
||||
</ResponsiveDropdownMenuItem>
|
||||
</ResponsiveDropdownMenuContent>
|
||||
</ResponsiveDropdownMenu>
|
||||
|
||||
{showViewDoc && (
|
||||
<LoadingButton
|
||||
className='w-full tablet:w-fit'
|
||||
loading={viewerBtnLoading}
|
||||
>
|
||||
<Icon name='ExternalLink' />
|
||||
Open in Viewer
|
||||
</ButtonLoading>
|
||||
) : null}
|
||||
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
state={copyDownloadState}
|
||||
icon={"Copy"}
|
||||
>
|
||||
Download Link
|
||||
</ButtonLoading>
|
||||
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
onClick={onDownload}
|
||||
state={downloadState}
|
||||
icon='Download'
|
||||
>
|
||||
Download
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card slot='file-info'>
|
||||
<CardHeader>
|
||||
<CardTitle>File Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{fileInfo.map((info) => (
|
||||
<div
|
||||
key={info.label}
|
||||
className='group flex flex-col'
|
||||
</LoadingButton>
|
||||
)}
|
||||
{showVirusTotal && (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full tablet:w-fit'
|
||||
>
|
||||
<span className='text-sm font-semibold'>{info.label}:</span>
|
||||
<span className='col-span-3 whitespace-pre-wrap break-all'>{info.value}</span>
|
||||
<Separator className='my-1.5 group-last-of-type:hidden' />
|
||||
</div>
|
||||
))}
|
||||
<Icon name='FileScan' />
|
||||
Virus Total
|
||||
</Button>
|
||||
)}
|
||||
<Button className='w-full tablet:w-fit'>
|
||||
<Icon name='Download' />
|
||||
Download File
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{fileInfo.map((info) => (
|
||||
<div
|
||||
key={info.label}
|
||||
className='grid items-center gap-2 border border-b-0 last-of-type:border-b tablet:grid-cols-2'
|
||||
>
|
||||
<span className='grow px-4 py-2 font-semibold tablet:border-r'>{info.label}</span>
|
||||
<span className='break-word grow whitespace-pre-wrap text-pretty px-4 py-2 text-sm'>{info.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <div className='grid grid-cols-1 gap-4'>
|
||||
// <Card slot='file-actions'>
|
||||
// <div className='flex flex-col-reverse gap-4 p-6 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
// <div
|
||||
// slot='raw-explanation'
|
||||
// className={cn("flex gap-4", !showRawUrl && "invisible")}
|
||||
// >
|
||||
// {isDesktop ? (
|
||||
// <Dialog
|
||||
// open={isRawExplanationOpen}
|
||||
// onOpenChange={setIsRawExplanationOpen}
|
||||
// >
|
||||
// <DialogTrigger asChild>
|
||||
// <span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
// Difference between download link and raw link?
|
||||
// </span>
|
||||
// </DialogTrigger>
|
||||
// <DialogContent>
|
||||
// <div className='flex h-fit flex-col gap-4'>
|
||||
// <h4>Why?</h4>
|
||||
// <p>
|
||||
// Some services need the file extension to be present in the URL to properly embed the file.
|
||||
// <br />
|
||||
// - The download link only have encrypted file id.
|
||||
// <br />
|
||||
// - The raw link will have the whole path includes the file name and extension.
|
||||
// <br />
|
||||
// <br />
|
||||
// So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
// download link.
|
||||
// </p>
|
||||
// <p className='muted'>Note: This is only applicable for video, audio, and image files.</p>
|
||||
// <h4>Ref</h4>
|
||||
// <p>
|
||||
// This information is based on the onedrive-vercel-index project documentation.
|
||||
// <br />
|
||||
// <Link
|
||||
// href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
// target='_blank'
|
||||
// rel='noreferrer noopener'
|
||||
// className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
// >
|
||||
// Customise Direct Link - onedrive-vercel-index
|
||||
// </Link>
|
||||
// </p>
|
||||
// </div>
|
||||
// </DialogContent>
|
||||
// </Dialog>
|
||||
// ) : (
|
||||
// <Drawer
|
||||
// open={isRawExplanationOpen}
|
||||
// onOpenChange={setIsRawExplanationOpen}
|
||||
// shouldScaleBackground
|
||||
// >
|
||||
// <DrawerTrigger asChild>
|
||||
// <span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
// Difference between download link and raw link?
|
||||
// </span>
|
||||
// </DrawerTrigger>
|
||||
// <DrawerContent>
|
||||
// <DrawerHeader></DrawerHeader>
|
||||
// <div className='flex h-fit flex-col gap-4 px-4'>
|
||||
// <h4>Why?</h4>
|
||||
// <p>
|
||||
// Some services need the file extension to be present in the URL to properly embed the file.
|
||||
// <br />
|
||||
// - The download link only have encrypted file id.
|
||||
// <br />
|
||||
// - The raw link will have the whole path includes the file name and extension.
|
||||
// <br />
|
||||
// <br />
|
||||
// So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
// download link.
|
||||
// </p>
|
||||
// <h4>Ref</h4>
|
||||
// <p>
|
||||
// This information is based on the onedrive-vercel-index project documentation.
|
||||
// <br />
|
||||
// <Link
|
||||
// href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
// target='_blank'
|
||||
// rel='noreferrer noopener'
|
||||
// className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
// >
|
||||
// Customise Direct Link - onedrive-vercel-index
|
||||
// </Link>
|
||||
// </p>
|
||||
// </div>
|
||||
// <DrawerFooter>
|
||||
// <DrawerClose asChild>
|
||||
// <Button
|
||||
// size={"sm"}
|
||||
// variant={"secondary"}
|
||||
// >
|
||||
// Close
|
||||
// </Button>
|
||||
// </DrawerClose>
|
||||
// </DrawerFooter>
|
||||
// </DrawerContent>
|
||||
// </Drawer>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// <div className='flex flex-col-reverse gap-4 tablet:flex-row tablet:items-center'>
|
||||
// {showRawUrl ? (
|
||||
// <LoadingButton
|
||||
// size={"sm"}
|
||||
// variant={"outline"}
|
||||
// onClick={onCopyRaw}
|
||||
// loading={copyBtnLoading === "loading"}
|
||||
// >
|
||||
// <Icon name='Copy' />
|
||||
// Raw Link
|
||||
// </LoadingButton>
|
||||
// ) : null}
|
||||
// {showViewDoc ? (
|
||||
// <LoadingButton
|
||||
// size={"sm"}
|
||||
// variant={"outline"}
|
||||
// onClick={onOpenViewer}
|
||||
// loading={viewerState === "loading"}
|
||||
// >
|
||||
// <Icon name='ExternalLink' />
|
||||
// Open in Viewer
|
||||
// </LoadingButton>
|
||||
// ) : null}
|
||||
|
||||
// <LoadingButton
|
||||
// size={"sm"}
|
||||
// variant={"outline"}
|
||||
// onClick={onCopy}
|
||||
// loading={copyDownloadBtnLoading === "loading"}
|
||||
// >
|
||||
// <Icon name='Link' />
|
||||
// Download Link
|
||||
// </LoadingButton>
|
||||
|
||||
// <LoadingButton
|
||||
// size={"sm"}
|
||||
// onClick={onDownload}
|
||||
// loading={downloadBtnLoading === "loading"}
|
||||
// >
|
||||
// <Icon name='Download' />
|
||||
// Download
|
||||
// </LoadingButton>
|
||||
// </div>
|
||||
// </div>
|
||||
// </Card>
|
||||
|
||||
// <Card slot='file-info'>
|
||||
// <CardHeader>
|
||||
// <CardTitle>File Information</CardTitle>
|
||||
// </CardHeader>
|
||||
// <CardContent>
|
||||
// <div className='flex flex-col gap-2'>
|
||||
// {fileInfo.map((info) => (
|
||||
// <div
|
||||
// key={info.label}
|
||||
// className='group flex flex-col'
|
||||
// >
|
||||
// <span className='text-sm font-semibold'>{info.label}:</span>
|
||||
// <span className='col-span-3 whitespace-pre-wrap break-all'>{info.value}</span>
|
||||
// <Separator className='my-1.5 group-last-of-type:hidden' />
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AsyncUnzipInflate, Unzip } from "fflate";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Icon, Loader, Status } from "~/components/global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
@@ -100,7 +100,8 @@ export default function PreviewManga({ file }: Props) {
|
||||
file.ondata = (err, data, final) => {
|
||||
if (err) throw err;
|
||||
// Check if the file is an image
|
||||
if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].some(ext => file.name.toLowerCase().endsWith(ext))) return;
|
||||
if (![".jpg", ".jpeg", ".png", ".gif", ".webp"].some((ext) => file.name.toLowerCase().endsWith(ext)))
|
||||
return;
|
||||
if (!final) return; // Skip if not fully loaded
|
||||
|
||||
const blob = new Blob([data]);
|
||||
@@ -175,9 +176,9 @@ export default function PreviewManga({ file }: Props) {
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col items-center justify-center gap-3'>
|
||||
<div className='flex w-full flex-col items-center justify-center gap-4'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
@@ -230,7 +231,7 @@ export default function PreviewManga({ file }: Props) {
|
||||
</div>
|
||||
<div
|
||||
slot='manga-info'
|
||||
className='flex w-full items-center justify-end gap-3'
|
||||
className='flex w-full items-center justify-end gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground'>
|
||||
{currentImage}/{images.length}
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Status } from "~/components/Global";
|
||||
import {
|
||||
PreviewAudio,
|
||||
PreviewDocument,
|
||||
PreviewImage,
|
||||
PreviewInformation,
|
||||
PreviewManga,
|
||||
PreviewRich,
|
||||
PreviewUnknown,
|
||||
PreviewVideo,
|
||||
} from "~/components/Preview";
|
||||
import { Status } from "~/components/global";
|
||||
import { PreviewInformation } from "~/components/preview";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
import { type getFileType } from "~/lib/previewHelper";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
@@ -31,21 +21,22 @@ type Props = {
|
||||
};
|
||||
export default function PreviewLayout({ data, fileType }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='preview-container'
|
||||
className='flex flex-col gap-3'
|
||||
className='flex flex-col gap-2 tablet:gap-4'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardHeader>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 overflow-hidden",
|
||||
"flex flex-col gap-4 overflow-hidden",
|
||||
"mobile:flex-row mobile:items-center mobile:justify-between",
|
||||
)}
|
||||
>
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{data.name}</h3>
|
||||
{["text", "markdown", "code"].includes(fileType) && (
|
||||
<CardTitle className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{data.name}</CardTitle>
|
||||
{["text", "markdown"].includes(fileType) && (
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
@@ -66,18 +57,19 @@ export default function PreviewLayout({ data, fileType }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
{/* <Separator /> */}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
{config.apiConfig.streamMaxSize && Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
|
||||
<CardContent>
|
||||
{config.apiConfig.streamMaxSize && Number(data.size ?? 0) > config.apiConfig.streamMaxSize ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='File is too large to preview'
|
||||
/>
|
||||
) : (
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<div>
|
||||
Preview goes here
|
||||
{/* {fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
@@ -87,7 +79,7 @@ export default function PreviewLayout({ data, fileType }: Props) {
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
view={view}
|
||||
view={"raw"}
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich
|
||||
@@ -107,7 +99,7 @@ export default function PreviewLayout({ data, fileType }: Props) {
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Markdown, Status } from "~/components/Global";
|
||||
import { Icon, Loader, Markdown, Status } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function PreviewRich({ file, code, view }: Props) {
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className='gap-1.5'
|
||||
className='gap-2'
|
||||
onClick={() => {
|
||||
setExpand((prev) => !prev);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
import { Loader, Status } from "~/components/global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
import { Loader, Status } from "~/components/global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as PreviewInformation } from "./Information";
|
||||
export { default as PreviewLayout } from "./Layout";
|
||||
export { default as PreviewLayout } from "./PreviewLayout";
|
||||
|
||||
export { default as PreviewAudio } from "./Audio";
|
||||
export { default as PreviewDocument } from "./Document";
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import * as React from "react";
|
||||
|
||||
import { buttonVariants } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -53,4 +53,4 @@ const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttrib
|
||||
);
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
@@ -23,7 +23,7 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
"flex flex-wrap items-center gap-2 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,7 +36,7 @@ const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWitho
|
||||
({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
className={cn("inline-flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
@@ -79,7 +79,7 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
|
||||
<li
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
className={cn("[&>svg]:h-3.5 [&>svg]:w-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import Icon from "./icon";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"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 hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"default": "bg-primary stroke-primary-foreground text-primary-foreground shadow hover:bg-primary/90",
|
||||
"destructive":
|
||||
"bg-destructive stroke-destructive-foreground text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"outline-destructive":
|
||||
"border border-input bg-background text-destructive stroke-destructive shadow-sm hover:bg-destructive hover:text-destructive-foreground hover:stroke-destructive-foreground",
|
||||
"ghost-destructive":
|
||||
"stroke-destructive hover:bg-destructive hover:text-destructive-foreground hover:stroke-destructive-foreground",
|
||||
"outline":
|
||||
"border border-input bg-background stroke-foreground shadow-sm hover:bg-accent hover:text-accent-foreground hover:stroke-accent-foreground",
|
||||
"secondary":
|
||||
"bg-secondary stroke-secondary-foreground text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
"ghost": "stroke-foreground hover:bg-accent hover:text-accent-foreground hover:stroke-accent-foreground",
|
||||
"link": "text-primary underline-offset-4 hover:underline stroke-primary",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
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: {
|
||||
@@ -50,4 +60,62 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export interface LoadingButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
const LoadingButton = React.forwardRef<HTMLButtonElement, LoadingButtonProps>(
|
||||
({ className, variant, size, asChild = false, children, loading, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }), "relative overflow-hidden")}
|
||||
ref={ref}
|
||||
disabled={disabled ? true : loading}
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 40,
|
||||
}}
|
||||
animate={{ opacity: loading ? 1 : 0, y: loading ? 0 : 40 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className='absolute'
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
className='animate-spin'
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: loading ? 0 : 1,
|
||||
y: loading ? -40 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className={cn("relative inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
LoadingButton.displayName = "LoadingButton";
|
||||
|
||||
export { Button, LoadingButton, buttonVariants };
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -22,20 +22,20 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
@@ -66,4 +66,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as React from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
@@ -227,4 +227,4 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
);
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi };
|
||||
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Dialog, DialogContent } from "~/components/ui/dialog";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className='overflow-hidden p-0'>
|
||||
<Command className='[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className='flex items-center border-b px-3'
|
||||
cmdk-input-wrapper=''
|
||||
>
|
||||
<Search className='mr-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className='py-6 text-center text-sm'
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.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 data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto h-4 w-4' />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.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}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
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}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.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 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'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.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 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'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className='h-4 w-4 fill-current' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Based from Credenza
|
||||
* https://github.com/redpangilinan/credenza
|
||||
*/
|
||||
import * as React from "react";
|
||||
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./drawer";
|
||||
|
||||
interface ResponsiveDialogRootProps extends React.PropsWithChildren {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
interface ResponsiveDialogProps extends React.PropsWithChildren {
|
||||
asChild?: true;
|
||||
className?: string;
|
||||
}
|
||||
interface ResponsiveDialogBodyProps extends React.PropsWithChildren, React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: true;
|
||||
}
|
||||
|
||||
const ResponsiveDialog = (props: ResponsiveDialogRootProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? Dialog : Drawer), [isDesktop]);
|
||||
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
const ResponsiveDialogTrigger = (props: ResponsiveDialogProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogTrigger : DrawerTrigger), [isDesktop]);
|
||||
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
const ResponsiveDialogClose = (props: ResponsiveDialogProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const loading = useLoading();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogClose : DrawerClose), [isDesktop]);
|
||||
|
||||
if (loading) return null;
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
const ResponsiveDialogContent = (props: ResponsiveDialogBodyProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogContent : DrawerContent), [isDesktop]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
aria-describedby={undefined}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponsiveDialogHeader = (
|
||||
props: ResponsiveDialogProps & {
|
||||
align?: "start" | "center" | "end";
|
||||
},
|
||||
) => {
|
||||
const { align = "start", className, ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogHeader : DrawerHeader), [isDesktop]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
align === "center" && "text-center",
|
||||
align === "start" && "text-start",
|
||||
align === "end" && "text-end",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponsiveDialogTitle = (props: ResponsiveDialogProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogTitle : DrawerTitle), [isDesktop]);
|
||||
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
const ResponsiveDialogDescription = (props: ResponsiveDialogProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const loading = useLoading();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogDescription : DrawerDescription), [isDesktop]);
|
||||
|
||||
if (loading) return null;
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
const ResponsiveDialogBody = (props: ResponsiveDialogBodyProps) => {
|
||||
const { className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("px-4 md:px-0", className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponsiveDialogFooter = (props: ResponsiveDialogProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const loading = useLoading();
|
||||
const Component = React.useMemo(() => (isDesktop ? DialogFooter : DrawerFooter), [isDesktop]);
|
||||
|
||||
if (loading) return null;
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
export {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogBody,
|
||||
ResponsiveDialogClose,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogDescription,
|
||||
ResponsiveDialogFooter,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
ResponsiveDialogTrigger,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
@@ -31,9 +31,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal container={props.portal}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
@@ -95,13 +95,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
@@ -33,9 +33,9 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal container={props.portal}>
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
@@ -54,7 +54,7 @@ DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
className={cn("grid gap-2 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Only supports DropdownMenuItem
|
||||
* Submenu, Radio, Checkbox not supported since not used on this project
|
||||
*/
|
||||
import * as React from "react";
|
||||
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
interface ResponsiveDropdownMenuRootProps extends React.PropsWithChildren {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
interface ResponsiveDropdownTriggerProps extends React.PropsWithChildren {
|
||||
asChild?: true;
|
||||
className?: string;
|
||||
}
|
||||
interface ResponsiveDropdownContentProps extends React.PropsWithChildren {
|
||||
asChild?: true;
|
||||
header?: {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
titleProps?: React.ComponentPropsWithRef<typeof DrawerTitle>;
|
||||
descriptionProps?: React.ComponentPropsWithRef<typeof DrawerDescription>;
|
||||
props?: React.ComponentPropsWithRef<typeof DrawerHeader>;
|
||||
};
|
||||
bodyProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
interface ResponsiveDropdownMenuItemProps extends React.PropsWithChildren {
|
||||
asChild?: true;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
}
|
||||
interface ResponsiveDropdownSeparatorProps extends React.PropsWithChildren {
|
||||
asChild?: true;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ResponsiveDropdownMenu = (props: ResponsiveDropdownMenuRootProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? DropdownMenu : Drawer), [isDesktop]);
|
||||
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
const ResponsiveDropdownMenuTrigger = (props: ResponsiveDropdownTriggerProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const Component = React.useMemo(() => (isDesktop ? DropdownMenuTrigger : DrawerTrigger), [isDesktop]);
|
||||
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
const ResponsiveDropdownMenuContent = (props: ResponsiveDropdownContentProps) => {
|
||||
const { children, header, bodyProps, ...rest } = props;
|
||||
const { className, ...bodyRest } = bodyProps ?? {};
|
||||
const { className: headerClassName, ...headerRest } = header?.props ?? {};
|
||||
const { isDesktop } = useResponsive();
|
||||
const loading = useLoading();
|
||||
const Component = React.useMemo(
|
||||
() =>
|
||||
isDesktop ? (
|
||||
<DropdownMenuContent {...rest}>{children}</DropdownMenuContent>
|
||||
) : (
|
||||
<DrawerContent {...rest}>
|
||||
{header && (
|
||||
<DrawerHeader
|
||||
className={cn("text-start", headerClassName)}
|
||||
{...headerRest}
|
||||
>
|
||||
<DrawerTitle {...header.titleProps}>{header.title}</DrawerTitle>
|
||||
{header.description && (
|
||||
<DrawerDescription {...header.descriptionProps}>{header.description}</DrawerDescription>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn("grid gap-2 px-4 pb-8 md:px-0", className)}
|
||||
{...bodyRest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
),
|
||||
[isDesktop, rest, children, header, headerClassName, headerRest, className, bodyRest],
|
||||
);
|
||||
|
||||
if (loading) return null;
|
||||
return Component;
|
||||
};
|
||||
const ResponsiveDropdownMenuItem = (props: ResponsiveDropdownMenuItemProps) => {
|
||||
const { selected, onSelect, closeOnSelect, ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const loading = useLoading();
|
||||
const Wrapper = React.useCallback<React.FC<{ children: React.ReactNode }>>(
|
||||
({ children }) =>
|
||||
closeOnSelect ? <DrawerClose asChild>{children}</DrawerClose> : <React.Fragment>{children}</React.Fragment>,
|
||||
[closeOnSelect],
|
||||
);
|
||||
|
||||
const Component = React.useMemo(
|
||||
() =>
|
||||
isDesktop ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={onSelect}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<Wrapper>
|
||||
<Button
|
||||
variant={selected ? "secondary" : "outline"}
|
||||
className='w-full'
|
||||
disabled={selected}
|
||||
onClick={onSelect}
|
||||
{...rest}
|
||||
/>
|
||||
</Wrapper>
|
||||
),
|
||||
[isDesktop, onSelect, rest, Wrapper, selected],
|
||||
);
|
||||
|
||||
if (loading) return null;
|
||||
return Component;
|
||||
};
|
||||
const ResponsiveDropdownMenuSeparator = (props: ResponsiveDropdownSeparatorProps) => {
|
||||
const { ...rest } = props;
|
||||
const { isDesktop } = useResponsive();
|
||||
const loading = useLoading();
|
||||
const Component = React.useMemo(() => (isDesktop ? DropdownMenuSeparator : Separator), [isDesktop]);
|
||||
|
||||
if (loading) return null;
|
||||
return <Component {...rest} />;
|
||||
};
|
||||
|
||||
export {
|
||||
ResponsiveDropdownMenu,
|
||||
ResponsiveDropdownMenuContent,
|
||||
ResponsiveDropdownMenuItem,
|
||||
ResponsiveDropdownMenuSeparator,
|
||||
ResponsiveDropdownMenuTrigger,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
@@ -27,14 +27,14 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
"flex cursor-default select-none items-center gap-2 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 h-4 w-4' />
|
||||
<ChevronRight className='ml-auto' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
@@ -56,14 +56,15 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal container={props.portal}>
|
||||
<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",
|
||||
"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}
|
||||
@@ -81,7 +82,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center 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",
|
||||
"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-[highlighted]:stroke-accent-foreground data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 ",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
|
||||
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("space-y-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
|
||||
({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { type LucideProps, icons } from "lucide-react";
|
||||
import { Fragment, useCallback } from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
export const IconNamesArray = Object.keys(icons) as [IconName, ...IconName[]];
|
||||
|
||||
type Props = {
|
||||
name: keyof typeof icons;
|
||||
className?: string;
|
||||
hideWrapper?: true;
|
||||
wrapperProps?: React.HTMLAttributes<HTMLSpanElement>;
|
||||
} & LucideProps;
|
||||
|
||||
export default function Icon({ name, className, hideWrapper, wrapperProps, ...props }: Props) {
|
||||
const LucideIcon = icons[name];
|
||||
const { className: wrapperClassName, ...restWrapperProps } = wrapperProps ?? {};
|
||||
// const Wrapper = hideWrapper ? Fragment : "span";
|
||||
const Wrapper = useCallback<React.FC<React.HTMLAttributes<HTMLSpanElement>>>(
|
||||
() =>
|
||||
hideWrapper ? (
|
||||
<Fragment>
|
||||
<LucideIcon
|
||||
className={cn("size-4 stroke-inherit", className)}
|
||||
{...props}
|
||||
/>
|
||||
</Fragment>
|
||||
) : (
|
||||
<span
|
||||
className={cn("grid aspect-square h-fit w-fit place-items-center", wrapperClassName)}
|
||||
{...restWrapperProps}
|
||||
>
|
||||
<LucideIcon
|
||||
className={cn("size-4 stroke-inherit", className)}
|
||||
{...props}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
[hideWrapper, wrapperClassName, restWrapperProps, LucideIcon, className, props],
|
||||
);
|
||||
|
||||
return <Wrapper />;
|
||||
}
|
||||
+16
-16
@@ -1,22 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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 };
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
|
||||
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto h-4 w-4' />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.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 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'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.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 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'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarItem,
|
||||
MenubarLabel,
|
||||
MenubarMenu,
|
||||
MenubarPortal,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
};
|
||||
@@ -3,17 +3,19 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal container={props.portal}>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
@@ -28,4 +30,4 @@ const PopoverContent = React.forwardRef<
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
@@ -11,7 +11,7 @@ const Progress = React.forwardRef<
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-border' />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -4,7 +4,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"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}
|
||||
@@ -98,7 +98,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -111,17 +111,16 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"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 left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<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>
|
||||
));
|
||||
@@ -141,13 +140,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
|
||||
+26
-27
@@ -5,7 +5,7 @@ import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||
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=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
"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: {
|
||||
@@ -40,7 +40,7 @@ const sheetVariants = cva(
|
||||
"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",
|
||||
"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: {
|
||||
@@ -53,25 +53,24 @@ interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps & { portal?: HTMLElement | null }
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal container={props.portal}>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<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>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
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>) => (
|
||||
@@ -116,13 +115,13 @@ SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetDescription,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user