[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";
@@ -138,7 +138,7 @@ Default: 5`,
inputKey: "proxyThumbnail",
title: "Allow Proxy Thumbnail",
description: `Use API route instead of Google Drive thumbnail link
If your files thumbnail are not accessible, you can enable this option
This will fetch the thumbnail image via API route`,
required: true,
@@ -157,7 +157,7 @@ This will fetch the thumbnail image via API route`,
inputKey: "allowDownloadProtectedFile",
title: "Allow Download Protected File",
description: `Allow users to download files inside the protected folder
If set to true, users will be able to download the file without entering the password as long as they have the direct link
If set to false, users will need to enter the password before downloading the file, even if they have the direct link`,
required: true,
@@ -317,7 +317,7 @@ Default: 100`,
return (
<form
className='flex w-full flex-col gap-3 py-3'
className='flex w-full flex-col gap-4 py-3'
onReset={onFormReset}
>
<ConfigurationHeader
@@ -328,7 +328,7 @@ Default: 100`,
<div
slot='inputs'
className='flex flex-col gap-3'
className='flex flex-col gap-4'
>
{inputs.map((props, index) => (
<ConfigurationInput<"api", ConfigurationKeys<"api">>
@@ -1,5 +1,5 @@
import { FormEvent, useState } from "react";
import toast from "react-hot-toast";
import { toast } from "sonner";
import { z } from "zod";
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
@@ -52,7 +52,7 @@ export default function ConfigEnvironment({ state: { get, set }, error, onReset
required: true,
action: {
label: "Generate",
state: encryptionState,
loading: encryptionState,
icon: "RefreshCw",
async onClick(e) {
e.preventDefault();
@@ -99,7 +99,7 @@ To avoid error when input, please use the "Load JSON" button to load the file.`,
readOnly: true,
action: {
label: "Load JSON",
state: serviceAuthState,
loading: serviceAuthState,
icon: "Upload",
async onClick(e) {
e.preventDefault();
@@ -160,7 +160,7 @@ Only required if the site is set to private`,
e.preventDefault();
setIsRevealPassword((prev) => !prev);
},
state: "idle",
loading: "idle",
},
value: get.environment.SITE_PASSWORD,
onValueChange: (key, value) => set("environment", key, value),
@@ -207,7 +207,7 @@ Only required if the site is set to private`,
return (
<form
className='flex w-full flex-col gap-3 py-3'
className='flex w-full flex-col gap-4 py-3'
onReset={onFormReset}
>
<ConfigurationHeader
@@ -218,7 +218,7 @@ Only required if the site is set to private`,
<div
slot='inputs'
className='flex flex-col gap-3'
className='flex flex-col gap-4'
>
{inputs.map((props, index) => (
<ConfigurationInput<"environment", ConfigurationKeys<"environment">>
+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>
+11 -11
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";
@@ -44,7 +44,7 @@ export default function ConfigSite({ state: { get, set }, error, onReset }: Prop
inputKey: "siteNameTemplate",
title: "Site Name Template",
description: `The template used to generate the site name
Available variables:
%s - Page title
%t - Site name`,
@@ -141,7 +141,7 @@ e.g. "file.mp4" instead of "file"`,
return (
<form
className='flex w-full flex-col gap-3 py-3'
className='flex w-full flex-col gap-4 py-3'
onReset={onFormReset}
>
<ConfigurationHeader title='Site Configuration' />
@@ -149,10 +149,10 @@ e.g. "file.mp4" instead of "file"`,
<div
slot='inputs'
className='flex flex-col gap-3'
className='flex flex-col gap-4'
>
<div className='grid grid-cols-3 gap-3'>
<div className='col-span-2 flex flex-col gap-3'>
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-2 flex flex-col gap-4'>
{metadataInputs.map((props, index) => (
<ConfigurationInput<"site", ConfigurationKeys<"site">>
key={String(props.inputKey)}
@@ -160,10 +160,10 @@ e.g. "file.mp4" instead of "file"`,
/>
))}
</div>
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-4'>
<div className='flex flex-col'>
<div className='flex flex-col items-start gap-1.5 pt-3'>
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
<div className='flex flex-col items-start gap-2 pt-3'>
<div className='flex select-none items-center gap-2 rounded-t-lg border border-border px-3 py-1.5'>
<img
src={get.site.favIcon}
className='size-4'
@@ -177,7 +177,7 @@ e.g. "file.mp4" instead of "file"`,
/>
</div>
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
<div className='flex select-none items-center gap-2 rounded-t-lg border border-border px-3 py-1.5'>
<img
src={get.site.favIcon}
className='size-4'
+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