Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
- Use `AutoForm` for forms instead of manually creating form components.
- After create/edit/delete operations, always refresh the data in the table to reflect the changes with notification using toast `sonner`.
- Use `React.lazy` and `Suspense` for code splitting and lazy loading for content-heavy dialogs like dialogs in forms.
- Don't use big comments. Remember that code is self-documenting. Use comments only when necessary to explain complex logic or decisions.
- Don't use big comments. Remember that code is self-documenting.
5 changes: 4 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ if (staticStorage) {
staticStorage.mountPath,
serveStatic({
root: staticStorage.root,
rewriteRequestPath: path => path.replace(staticStorage.stripPrefix, ""),
rewriteRequestPath: path =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
);
}
Expand Down
79 changes: 77 additions & 2 deletions apps/api/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"@vitnode/core:system:can_view": "View integrations",
"@vitnode/core:system:can_send_test_email": "Send test email",
"@vitnode/core:system:can_test_storage": "Test storage",
"@vitnode/core:files": "Files",
"@vitnode/core:files:can_view": "View uploaded files",
"@vitnode/core:files:can_download": "Download files",
"@vitnode/core:files:can_delete": "Delete files",
"@vitnode/core:queue": "Queue Tasks",
"@vitnode/core:queue:can_view": "View queue tasks",
"@vitnode/core:staff_moderators": "Staff: Moderators",
Expand Down Expand Up @@ -132,7 +136,41 @@
"log_out": "Log out",
"mod_cp": "Moderator CP",
"admin_cp": "Admin CP",
"settings": "Settings"
"settings": "Settings",
"files": "My Files"
}
},
"files": {
"title": "My Files",
"desc": "Files you've uploaded to your account.",
"list": {
"preview": "Preview",
"name": "Name",
"size": "Size",
"dimensions": "Dimensions",
"metadata": "Metadata",
"createdAt": "Uploaded"
},
"metadata": {
"title": "Metadata",
"empty": "—"
},
"actions": {
"download": "Download",
"delete": "Delete"
},
"download": {
"error": "Failed to download the file."
},
"delete": {
"title": "Delete file",
"desc": "Are you sure you want to delete this file? This permanently removes it and cannot be undone.",
"confirm": "Delete",
"success": "File deleted."
},
"noResults": {
"title": "No files yet",
"description": "Files you upload will appear here."
}
},
"auth": {
Expand Down Expand Up @@ -273,7 +311,8 @@
},
"system": {
"title": "System",
"integrations": "Integrations"
"integrations": "Integrations",
"files": "Files"
},
"advanced": {
"title": "Advanced",
Expand Down Expand Up @@ -598,6 +637,42 @@
"queued": "{pending} pending · {processing} running",
"cron_stale": "Offline — the cron worker that drains the queue isn't running. Fix cron jobs to resume processing."
}
},
"files": {
"title": "Files",
"desc": "Browse, download, and delete files uploaded to this instance.",
"list": {
"preview": "Preview",
"name": "Name",
"size": "Size",
"dimensions": "Dimensions",
"uploadedBy": "Uploaded by",
"metadata": "Metadata",
"createdAt": "Uploaded",
"no_preview": "No preview"
},
"anonymous": "—",
"metadata": {
"empty": "—",
"title": "Metadata"
},
"actions": {
"download": "Download",
"delete": "Delete"
},
"download": {
"error": "Failed to download the file."
},
"delete": {
"title": "Delete file",
"desc": "Are you sure you want to delete this file? This permanently removes it from storage and cannot be undone.",
"confirm": "Delete",
"success": "File deleted."
},
"noResults": {
"title": "No files uploaded",
"description": "Files uploaded across the site will appear here."
}
}
},
"debug": {
Expand Down
22 changes: 19 additions & 3 deletions apps/docs/content/docs/dev/storage/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,25 @@ export const vitNodeApiConfig = buildApiConfig({
```

It runs automatically inside `c.get("storage").upload()`, so every upload route
benefits. Applies to JPEG, PNG, WebP, AVIF and TIFF; the format is preserved.
Other files — including SVG and GIF — are stored untouched. Leave `image` out to
store originals as-is.
benefits. Applies to JPEG, PNG, WebP, AVIF and TIFF. Other files — including SVG
and GIF — are stored untouched. Leave `image` out to store originals as-is.

Processed images are also **converted to WebP** by default — usually much
smaller than JPEG or PNG at the same quality — and their pixel dimensions are
recorded in `core_files.metadata.dimensions` (shown in the admin **Files**
panel). Set `webp: false` to keep each image in its original format:

```ts title="vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: LocalStorageAdapter(),
image: {
quality: 85,
webp: false, // [!code ++] keep the original format
},
},
});
```

## Create your own upload endpoint

Expand Down
5 changes: 4 additions & 1 deletion apps/docs/content/docs/dev/storage/local.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ if (staticStorage) {
staticStorage.mountPath,
serveStatic({
root: staticStorage.root,
rewriteRequestPath: path => path.replace(staticStorage.stripPrefix, ""),
rewriteRequestPath: path =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { getTranslations } from "next-intl/server";
import dynamic from "next/dynamic";
import { notFound } from "next/navigation";
import React from "react";

import { I18nProvider } from "@vitnode/core/components/i18n-provider";
import { DataTableSkeleton } from "@vitnode/core/components/table/data-table";
import { HeaderContent } from "@vitnode/core/components/ui/header-content";
import { getSessionApi } from "@vitnode/core/lib/api/get-session-api";

const MyFilesTableView = dynamic(async () =>
import("@vitnode/core/views/files/my-files-table-view").then(module => ({
default: module.MyFilesTableView,
})),
);

export const generateMetadata = async () => {
const t = await getTranslations("core.files");

return {
title: t("title"),
description: t("desc"),
robots: { index: false, follow: false },
};
};

export default async function Page(
props: React.ComponentProps<typeof MyFilesTableView>,
) {
const [t, session] = await Promise.all([
getTranslations("core.files"),
getSessionApi(),
]);

if (!session.user) {
notFound();
}

return (
<I18nProvider namespaces={["core.files"]}>
<div className="container mx-auto space-y-6 p-4">
<HeaderContent desc={t("desc")} h1={t("title")} />

<React.Suspense fallback={<DataTableSkeleton columns={7} />}>
<MyFilesTableView {...props} />
</React.Suspense>
</div>
</I18nProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { getTranslations } from "next-intl/server";
import dynamic from "next/dynamic";
import { notFound } from "next/navigation";
import React from "react";

import { I18nProvider } from "@vitnode/core/components/i18n-provider";
import { DataTableSkeleton } from "@vitnode/core/components/table/data-table";
import { HeaderContent } from "@vitnode/core/components/ui/header-content";
import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api";

const FilesTableView = dynamic(async () =>
import("@vitnode/core/views/admin/views/core/system/files/files-table-view").then(
module => ({
default: module.FilesTableView,
}),
),
);

export const generateMetadata = async () => {
const t = await getTranslations("admin.system.files");

return {
title: t("title"),
description: t("desc"),
};
};

export default async function Page(
props: React.ComponentProps<typeof FilesTableView>,
) {
const [t, canView] = await Promise.all([
getTranslations("admin.system.files"),
checkAdminPermissionApi({ module: "files", permission: "can_view" }),
]);

if (!canView) {
notFound();
}

return (
<I18nProvider namespaces={["admin.system.files"]}>
<div className="p-4">
<HeaderContent desc={t("desc")} h1={t("title")} />

<React.Suspense fallback={<DataTableSkeleton columns={8} />}>
<FilesTableView {...props} />
</React.Suspense>
</div>
</I18nProvider>
);
}
79 changes: 77 additions & 2 deletions apps/docs/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"@vitnode/core:system:can_view": "View integrations",
"@vitnode/core:system:can_send_test_email": "Send test email",
"@vitnode/core:system:can_test_storage": "Test storage",
"@vitnode/core:files": "Files",
"@vitnode/core:files:can_view": "View uploaded files",
"@vitnode/core:files:can_download": "Download files",
"@vitnode/core:files:can_delete": "Delete files",
"@vitnode/core:queue": "Queue Tasks",
"@vitnode/core:queue:can_view": "View queue tasks",
"@vitnode/core:staff_moderators": "Staff: Moderators",
Expand Down Expand Up @@ -132,7 +136,41 @@
"log_out": "Log out",
"mod_cp": "Moderator CP",
"admin_cp": "Admin CP",
"settings": "Settings"
"settings": "Settings",
"files": "My Files"
}
},
"files": {
"title": "My Files",
"desc": "Files you've uploaded to your account.",
"list": {
"preview": "Preview",
"name": "Name",
"size": "Size",
"dimensions": "Dimensions",
"metadata": "Metadata",
"createdAt": "Uploaded"
},
"metadata": {
"title": "Metadata",
"empty": "—"
},
"actions": {
"download": "Download",
"delete": "Delete"
},
"download": {
"error": "Failed to download the file."
},
"delete": {
"title": "Delete file",
"desc": "Are you sure you want to delete this file? This permanently removes it and cannot be undone.",
"confirm": "Delete",
"success": "File deleted."
},
"noResults": {
"title": "No files yet",
"description": "Files you upload will appear here."
}
},
"auth": {
Expand Down Expand Up @@ -273,7 +311,8 @@
},
"system": {
"title": "System",
"integrations": "Integrations"
"integrations": "Integrations",
"files": "Files"
},
"advanced": {
"title": "Advanced",
Expand Down Expand Up @@ -598,6 +637,42 @@
"queued": "{pending} pending · {processing} running",
"cron_stale": "Offline — the cron worker that drains the queue isn't running. Fix cron jobs to resume processing."
}
},
"files": {
"title": "Files",
"desc": "Browse, download, and delete files uploaded to this instance.",
"list": {
"preview": "Preview",
"name": "Name",
"size": "Size",
"dimensions": "Dimensions",
"uploadedBy": "Uploaded by",
"metadata": "Metadata",
"createdAt": "Uploaded",
"no_preview": "No preview"
},
"anonymous": "—",
"metadata": {
"empty": "—",
"title": "Metadata"
},
"actions": {
"download": "Download",
"delete": "Delete"
},
"download": {
"error": "Failed to download the file."
},
"delete": {
"title": "Delete file",
"desc": "Are you sure you want to delete this file? This permanently removes it from storage and cannot be undone.",
"confirm": "Delete",
"success": "File deleted."
},
"noResults": {
"title": "No files uploaded",
"description": "Files uploaded across the site will appear here."
}
}
},
"debug": {
Expand Down
8 changes: 6 additions & 2 deletions packages/vitnode/src/api/adapters/storage/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ export const LocalStorageAdapter = ({
// trace to `public/uploads` instead of the whole project.
const resolvePath = (key: string): string =>
join(process.cwd(), "public", "uploads", key);
const getUrl = (key: string): string =>
`${baseUrl ?? CONFIG.api.origin}${publicPath}/${key}`;
const getUrl = (key: string): string => {
const base = (baseUrl ?? CONFIG.api.origin).replace(/\/$/, "");
const path = publicPath.replace(/^\/|\/$/g, "");

return `${base}/${path}/${key}`;
};
// Route registered on the API app, which already carries the `/api` basePath.
const mountPath = `${publicPath.replace(/^\/api/, "")}/*`;

Expand Down
Loading
Loading