[Update] Update various configuration files, ESLint config, Prettier config, and added new files including a CHANGELOG.md.

This commit is contained in:
mbaharip
2025-01-01 09:02:47 +07:00
parent b8ad5ad890
commit fdb55cccf5
139 changed files with 5770 additions and 12296 deletions
+1 -3
View File
@@ -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=
+43
View File
@@ -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;
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off"
},
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"]
}
+1
View File
@@ -35,6 +35,7 @@ next-env.d.ts
/docs
/src/pages/api/legacy/
/**/*/_legacy/
/**/*/_*/
/data
/.*/
/src/_app
+2 -2
View File
@@ -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)$",
"^[./]",
],
View File
BIN
View File
Binary file not shown.
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+351
View File
@@ -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: "",
};
}
+250
View File
@@ -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,
};
}
}
+182
View File
@@ -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,
};
}
+116
View File
@@ -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,
};
}
+5 -5
View File
@@ -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
View File
@@ -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>
// );
}
+3 -3
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { decryptData } from "~/utils/encryptionHelper";
+67
View File
@@ -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),
},
);
}
}
+4 -4
View File
@@ -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);
+9 -11
View File
@@ -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
View File
@@ -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>
-123
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-499
View File
@@ -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>
);
}
+555
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+328
View File
@@ -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> */}
</>
);
}
+129
View File
@@ -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>
);
}
-156
View File
@@ -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
-270
View File
@@ -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>
);
}
-129
View File
@@ -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>
);
}
-260
View File
@@ -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>
);
}
+5 -6
View File
@@ -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 -2
View File
@@ -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";
-19
View File
@@ -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>
);
}
+9 -10
View File
@@ -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"]}
+8 -7
View File
@@ -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 -2
View File
@@ -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";
@@ -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">>
+10 -13
View File
@@ -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"}
+13 -12
View File
@@ -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>
+15 -20
View File
@@ -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>
+10 -10
View File
@@ -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";
@@ -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'
+3 -3
View File
@@ -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}`}>
+14 -15
View File
@@ -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>
+7 -7
View File
@@ -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={{
+11 -13
View File
@@ -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>}
+53 -34
View File
@@ -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
View File
@@ -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>
-36
View File
@@ -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>
);
}
+42 -62
View File
@@ -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>
);
+52
View File
@@ -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>
);
}
+2 -4
View File
@@ -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>
-53
View File
@@ -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>
);
}
+6 -8
View File
@@ -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>
);
+3 -3
View File
@@ -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";
+1 -1
View File
@@ -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";
+3 -3
View File
@@ -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 -3
View File
@@ -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'
+352 -202
View File
@@ -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&apos;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&apos;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&apos;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&apos;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&apos;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>
// );
}
+7 -6
View File
@@ -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 -3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Loader, Status } from "~/components/Global";
import { Loader, Status } from "~/components/global";
import useLoading from "~/hooks/useLoading";
+1 -1
View File
@@ -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 -1
View File
@@ -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";
+125
View File
@@ -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,
};
+3 -3
View File
@@ -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 };
+4 -4
View File
@@ -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 />}
+81 -13
View File
@@ -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 };
+8 -8
View File
@@ -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 };
+2 -2
View File
@@ -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 };
+151
View File
@@ -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,
};
+189
View File
@@ -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,
};
+156
View File
@@ -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,
};
+8 -8
View File
@@ -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,
};
+4 -4
View File
@@ -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,
};
+8 -7
View File
@@ -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,
)}
+155
View File
@@ -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 };
+46
View File
@@ -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
View File
@@ -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 };
+1 -1
View File
@@ -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");
-218
View File
@@ -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,
};
+6 -4
View File
@@ -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 };
+2 -2
View File
@@ -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
+44
View File
@@ -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 };
+12 -13
View File
@@ -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,
};
+1 -1
View File
@@ -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
View File
@@ -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,
};
+2 -2
View File
@@ -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