├── .prettierrc
├── src
├── vite-env.d.ts
├── lib
│ └── utils.ts
├── components
│ ├── TopRightItems.tsx
│ ├── DropZoneOverlay.tsx
│ ├── ConvexProviderWrapper.tsx
│ ├── ui
│ │ ├── toast.tsx
│ │ ├── input.tsx
│ │ ├── toggle.tsx
│ │ ├── toggle-group.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ └── dropdown-menu.tsx
│ ├── SelectFilesButton.tsx
│ ├── SelectionBox.tsx
│ ├── AppTitle.tsx
│ ├── EmptyState.tsx
│ ├── UnselectedItem.tsx
│ ├── FileGhostPreview.tsx
│ ├── MultiFileDownloadDialog.tsx
│ ├── DeleteFileDialog.tsx
│ ├── MainMenu.tsx
│ ├── FileTypeIcon.tsx
│ ├── SettingsDialog.tsx
│ ├── MultiSelectOverlay.tsx
│ ├── UploadStatusIndicator.tsx
│ ├── SelectedItem.tsx
│ ├── FileIcon.tsx
│ ├── FileUpload.tsx
│ └── FileTooltip.tsx
├── App.tsx
├── main.tsx
├── utils
│ └── formatters.ts
├── hooks
│ ├── useKeyboardShortcuts.ts
│ ├── useIsMobile.ts
│ ├── useFileCreator.ts
│ ├── useFileHandlers.ts
│ ├── useOptimisticFiles.ts
│ ├── useSelectionBox.ts
│ ├── useFileUploader.ts
│ ├── useDragPosition.ts
│ └── useFileDownloadDrag.ts
├── index.css
└── contexts
│ └── SettingsContext.tsx
├── media
├── ss1.png
├── ss2.png
└── ss3.png
├── public
├── icon.png
├── logo.afphoto
└── logo.svg
├── .gitattributes
├── postcss.config.js
├── tsconfig.json
├── self-hosted
├── README.md
├── Dockerfile
└── run.sh
├── vite.config.ts
├── tsconfig.node.json
├── index.html
├── components.json
├── .gitignore
├── convex
├── _generated
│ ├── api.js
│ ├── api.d.ts
│ ├── dataModel.d.ts
│ ├── server.js
│ └── server.d.ts
├── tsconfig.json
├── schema.ts
├── crons.ts
├── constants.ts
├── README.md
└── files.ts
├── .env.local.example
├── Dockerfile
├── .dockerignore
├── tsconfig.app.json
├── LICENSE
├── .eslintrc.cjs
├── docker-compose.yml
├── package.json
├── README.md
├── tailwind.config.js
└── .cursor
└── rules
└── convex_rules.mdc
/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/media/ss1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/ourfiles/HEAD/media/ss1.png
--------------------------------------------------------------------------------
/media/ss2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/ourfiles/HEAD/media/ss2.png
--------------------------------------------------------------------------------
/media/ss3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/ourfiles/HEAD/media/ss3.png
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/ourfiles/HEAD/public/icon.png
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/public/logo.afphoto:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikecann/ourfiles/HEAD/public/logo.afphoto
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/self-hosted/README.md:
--------------------------------------------------------------------------------
1 | This docker file is borrowed from the official Convex repo if you want an up to date version of this and more instructions see this file: https://github.com/get-convex/convex-backend/blob/main/self-hosted/docker/README.md
--------------------------------------------------------------------------------
/src/components/TopRightItems.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { MainMenu } from "./MainMenu";
3 |
4 | export const TopRightItems: React.FC = () => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { FileUpload } from "./components/FileUpload";
2 | import { AppTitle } from "./components/AppTitle";
3 |
4 | export default function App() {
5 | return (
6 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@": path.resolve(__dirname, "./src"),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/src/components/DropZoneOverlay.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export const DropZoneOverlay: React.FC = () => {
4 | return (
5 |
6 |
Drop files here...
7 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/self-hosted/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ghcr.io/get-convex/convex-backend:latest
2 |
3 | WORKDIR /convex
4 |
5 | RUN npm install -g bun
6 | COPY package.json bun.lock ./
7 | RUN bun install
8 |
9 | COPY self-hosted/run.sh .
10 | COPY convex convex
11 |
12 | ENTRYPOINT ["/bin/bash", "./run.sh"]
13 |
14 | VOLUME /convex/data
15 | VOLUME /convex/convex
16 |
17 | EXPOSE 3210
18 | EXPOSE 3211
19 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | OurFiles
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "stone",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Ignored for the template, you probably want to remove it:
27 | package-lock.json
--------------------------------------------------------------------------------
/src/components/ConvexProviderWrapper.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ConvexProvider, ConvexReactClient } from "convex/react";
3 | import { useSettings } from "../contexts/SettingsContext";
4 |
5 | export const ConvexProviderWrapper: React.FC<{ children: React.ReactNode }> = ({
6 | children,
7 | }) => {
8 | const { settings } = useSettings();
9 | const convex = new ConvexReactClient(settings.convexUrl);
10 | return {children};
11 | };
12 |
--------------------------------------------------------------------------------
/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import { anyApi } from "convex/server";
12 |
13 | /**
14 | * A utility for referencing Convex functions in your app's API.
15 | *
16 | * Usage:
17 | * ```js
18 | * const myFunctionReference = api.myModule.myFunction;
19 | * ```
20 | */
21 | export const api = anyApi;
22 | export const internal = anyApi;
23 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | # Deployed to Convex Cloud
2 | #CONVEX_DEPLOYMENT=dev:proper-sheep-873
3 | #VITE_CONVEX_DASHBOARD_URL=https://dashboard.convex.dev/d/proper-sheep-873
4 | #VITE_CONVEX_URL=https://proper-sheep-873.convex.cloud
5 |
6 | # Localhost
7 | VITE_CONVEX_URL='http://localhost:3210'
8 | CONVEX_SELF_HOSTED_URL='http://localhost:3210'
9 | VITE_CONVEX_DASHBOARD_URL='http://localhost:6791'
10 | CONVEX_SELF_HOSTED_ADMIN_KEY='' # e.g. 'convex-self-hosted|01ff18697486491daecb1b30e535a97fedeb8879e76b467f0ccec385ba6c350676dd11e305720825c331ea1042174378a0'
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM oven/bun:1 as builder
2 |
3 | WORKDIR /app
4 |
5 | ARG VITE_CONVEX_URL
6 | ARG VITE_CONVEX_DASHBOARD_URL
7 | ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
8 | ENV VITE_CONVEX_DASHBOARD_URL=$VITE_CONVEX_DASHBOARD_URL
9 |
10 | COPY package.json bun.lock ./
11 |
12 | # Install dependencies
13 | RUN bun install
14 |
15 | # Copy application files
16 | COPY . .
17 | RUN bun run build
18 |
19 | FROM nginx:stable-alpine
20 |
21 | COPY --from=builder /app/dist /usr/share/nginx/html
22 |
23 | # Expose necessary ports
24 | EXPOSE 80
25 |
26 | CMD ["nginx", "-g", "daemon off;"]
27 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster as SonnerToaster } from "sonner";
2 |
3 | export function Toaster() {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 | **/game/node_modules
4 | .pnp
5 | **/.pnp.js
6 |
7 | # testing
8 | coverage
9 |
10 | # next.js
11 | .next
12 | out
13 |
14 | # production
15 | build
16 |
17 | # misc
18 | **/.DS_Store
19 | **/*.pem
20 |
21 | # debug
22 | **/npm-debug.log*
23 | **/yarn-debug.log*
24 | **/yarn-error.log*
25 |
26 | # local env files
27 | **/.env*.local
28 |
29 | # vercel
30 | **/.vercel
31 |
32 | # typescript
33 | **/*.tsbuildinfo
34 | **/next-env.d.ts
35 | **/.env
36 | .env.prod
37 | fly.toml
38 |
39 | # Vite build
40 | **/dist
41 |
42 | **/convex-local*
43 | **/convex_local*
44 |
--------------------------------------------------------------------------------
/self-hosted/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | ./run_backend.sh &
6 | BACKEND_PID=$!
7 |
8 | # Wait for the backend to start
9 | while ! curl -f http://127.0.0.1:3210/instance_name >/dev/null 2>&1; do
10 | sleep .1
11 | done
12 |
13 | ADMIN_KEY=$(./generate_admin_key.sh)
14 |
15 | # Make a .env.local file with the admin key
16 | echo "CONVEX_SELF_HOSTED_ADMIN_KEY=$ADMIN_KEY" >>.env.local
17 | echo "CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210" >>.env.local
18 |
19 | # Deploy the current code to the backend
20 | npx convex self-host dev --until-success
21 |
22 | # Handle SIGTERM
23 | # shellcheck disable=SC2064
24 | trap "kill $BACKEND_PID" SIGTERM
25 |
26 | # Run the backend, handling SIGTERM.
27 | wait $BACKEND_PID
28 |
--------------------------------------------------------------------------------
/src/components/SelectFilesButton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Button } from "./ui/button";
3 | import { Plus } from "lucide-react";
4 |
5 | type ActionButtonsProps = {
6 | onAddClick: () => void;
7 | };
8 |
9 | export const AddItemsButton: React.FC = ({
10 | onAddClick,
11 | }) => {
12 | return (
13 | <>
14 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/SelectionBox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type SelectionBoxProps = {
4 | start: { x: number; y: number };
5 | current: { x: number; y: number };
6 | };
7 |
8 | export const SelectionBox: React.FC = ({
9 | start,
10 | current,
11 | }) => {
12 | const left = Math.min(start.x, current.x);
13 | const top = Math.min(start.y, current.y);
14 | const width = Math.abs(current.x - start.x);
15 | const height = Math.abs(current.y - start.y);
16 |
17 | return (
18 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { ThemeProvider } from "next-themes";
4 | import App from "./App.tsx";
5 | import "./index.css";
6 | import { SettingsProvider } from "./contexts/SettingsContext";
7 | import { ConvexProviderWrapper } from "./components/ConvexProviderWrapper";
8 |
9 | ReactDOM.createRoot(document.getElementById("root")!).render(
10 |
11 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ,
24 | );
25 |
--------------------------------------------------------------------------------
/src/utils/formatters.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Formats a file size in bytes to a human readable string
3 | * Automatically chooses the most appropriate unit (B, KB, MB, GB, TB)
4 | */
5 | export function formatFileSize(bytes: number): string {
6 | const units = ["B", "KB", "MB", "GB", "TB"];
7 | let size = bytes;
8 | let unitIndex = 0;
9 |
10 | while (size >= 1024 && unitIndex < units.length - 1) {
11 | size /= 1024;
12 | unitIndex++;
13 | }
14 |
15 | // For bytes, show no decimal places
16 | if (unitIndex === 0) return `${Math.round(size)} ${units[unitIndex]}`;
17 |
18 | // For KB and above, show 1 decimal place if the number is small
19 | // For larger numbers (>=100), show no decimal places
20 | const decimals = size >= 100 ? 0 : 1;
21 | return `${size.toFixed(decimals)} ${units[unitIndex]}`;
22 | }
23 |
--------------------------------------------------------------------------------
/src/hooks/useKeyboardShortcuts.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Id } from "../../convex/_generated/dataModel";
3 |
4 | export const useKeyboardShortcuts = ({
5 | selectedIds,
6 | onDelete,
7 | onClearSelection,
8 | }: {
9 | selectedIds: Set>;
10 | onDelete: () => void;
11 | onClearSelection: () => void;
12 | }) => {
13 | const handleKeyDown = React.useCallback(
14 | (e: KeyboardEvent) => {
15 | if (e.key === "Delete" && selectedIds.size > 0) onDelete();
16 | if (e.key === "Escape") onClearSelection();
17 | },
18 | [selectedIds, onDelete, onClearSelection],
19 | );
20 |
21 | React.useEffect(() => {
22 | window.addEventListener("keydown", handleKeyDown);
23 | return () => window.removeEventListener("keydown", handleKeyDown);
24 | }, [handleKeyDown]);
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/AppTitle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export const AppTitle: React.FC = () => {
4 | return (
5 |
6 |
7 |

12 |
13 |
14 | OurFiles
15 |
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/hooks/useIsMobile.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | const MOBILE_USER_AGENT =
4 | /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
5 |
6 | export default function useIsMobile() {
7 | const getIsMobile = () =>
8 | typeof window !== "undefined" &&
9 | (window.innerWidth <= 768 || MOBILE_USER_AGENT.test(navigator.userAgent));
10 |
11 | const [isMobile, setIsMobile] = useState(getIsMobile());
12 |
13 | useEffect(() => {
14 | const update = () => setIsMobile(getIsMobile());
15 | window.addEventListener("resize", update);
16 | window.addEventListener("orientationchange", update);
17 | return () => {
18 | window.removeEventListener("resize", update);
19 | window.removeEventListener("orientationchange", update);
20 | };
21 | }, []);
22 |
23 | return isMobile;
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export const EmptyState: React.FC = () => {
4 | return (
5 |
9 |
22 |
Drag and drop files here
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 | "moduleResolution": "Bundler",
11 | "jsx": "react-jsx",
12 | "skipLibCheck": true,
13 | "allowSyntheticDefaultImports": true,
14 |
15 | /* These compiler options are required by Convex */
16 | "target": "ESNext",
17 | "lib": ["ES2021", "dom"],
18 | "forceConsistentCasingInFileNames": true,
19 | "module": "ESNext",
20 | "isolatedModules": true,
21 | "noEmit": true
22 | },
23 | "include": ["./**/*"],
24 | "exclude": ["./_generated"]
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "moduleDetection": "force",
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 |
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": false,
23 | "noUnusedParameters": false,
24 | "noFallthroughCasesInSwitch": true,
25 |
26 | /* Import paths */
27 | "paths": {
28 | "@/*": ["./src/*"]
29 | }
30 | },
31 | "include": ["src"]
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | files: defineTable({
6 | name: v.string(),
7 | size: v.number(),
8 | type: v.string(),
9 | position: v.object({
10 | x: v.number(),
11 | y: v.number(),
12 | }),
13 | uploadState: v.union(
14 | v.object({
15 | kind: v.literal("created"),
16 | }),
17 | v.object({
18 | kind: v.literal("uploading"),
19 | progress: v.number(),
20 | lastProgressAt: v.number(),
21 | timeoutJobId: v.id("_scheduled_functions"),
22 | }),
23 | v.object({
24 | kind: v.literal("uploaded"),
25 | storageId: v.id("_storage"),
26 | url: v.string(),
27 | }),
28 | v.object({
29 | kind: v.literal("errored"),
30 | message: v.string(),
31 | }),
32 | ),
33 | }),
34 | });
35 |
--------------------------------------------------------------------------------
/src/components/UnselectedItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { FileIcon } from "./FileIcon";
3 | import { Doc } from "../../convex/_generated/dataModel";
4 | import { useFileDownloadDrag } from "../hooks/useFileDownloadDrag";
5 |
6 | type UnselectedItemProps = {
7 | file: Doc<"files">;
8 | onClick: (e: React.MouseEvent) => void;
9 | isDragSelecting?: boolean;
10 | };
11 |
12 | export const UnselectedItem: React.FC = ({
13 | file,
14 | onClick,
15 | isDragSelecting,
16 | }) => {
17 | const { handleDragStart, handleDragEnd, canDownload } = useFileDownloadDrag({
18 | files: [file],
19 | singleFile: true,
20 | });
21 |
22 | return (
23 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/convex/crons.ts:
--------------------------------------------------------------------------------
1 | import { cronJobs } from "convex/server";
2 | import { internal, api } from "./_generated/api";
3 | import { internalMutation } from "./_generated/server";
4 | import { v } from "convex/values";
5 | import { Doc } from "./_generated/dataModel";
6 | import { FILE_CLEAR_INTERVAL_MINS } from "./constants";
7 |
8 | export const wipeFiles = internalMutation({
9 | args: {},
10 | returns: v.null(),
11 | handler: async (ctx) => {
12 | const files = await ctx.db.query("files").collect();
13 | const fileIds = files.map((file: Doc<"files">) => file._id);
14 | if (fileIds.length > 0)
15 | await ctx.runMutation(api.files.remove, { ids: fileIds });
16 | return null;
17 | },
18 | });
19 |
20 | const crons = cronJobs();
21 |
22 | // Only register the cron if interval is positive
23 | if (FILE_CLEAR_INTERVAL_MINS > 0)
24 | crons.interval(
25 | "wipe-files",
26 | { minutes: FILE_CLEAR_INTERVAL_MINS },
27 | internal.crons.wipeFiles,
28 | {},
29 | );
30 |
31 | export default crons;
32 |
--------------------------------------------------------------------------------
/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | ApiFromModules,
13 | FilterApi,
14 | FunctionReference,
15 | } from "convex/server";
16 | import type * as constants from "../constants.js";
17 | import type * as crons from "../crons.js";
18 | import type * as files from "../files.js";
19 |
20 | /**
21 | * A utility for referencing Convex functions in your app's API.
22 | *
23 | * Usage:
24 | * ```js
25 | * const myFunctionReference = api.myModule.myFunction;
26 | * ```
27 | */
28 | declare const fullApi: ApiFromModules<{
29 | constants: typeof constants;
30 | crons: typeof crons;
31 | files: typeof files;
32 | }>;
33 | export declare const api: FilterApi<
34 | typeof fullApi,
35 | FunctionReference
36 | >;
37 | export declare const internal: FilterApi<
38 | typeof fullApi,
39 | FunctionReference
40 | >;
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Mike Cann
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/convex/constants.ts:
--------------------------------------------------------------------------------
1 | import { query } from "./_generated/server";
2 |
3 | // Helper to safely get environment variables with proper typing
4 | const getEnvVar = ({
5 | name,
6 | parse,
7 | defaultValue,
8 | }: {
9 | name: string;
10 | parse: (value: string) => T;
11 | defaultValue: T;
12 | }): T =>
13 | typeof process === "undefined" || !process.env[name]
14 | ? defaultValue
15 | : parse(process.env[name]);
16 |
17 | // File size limits
18 | const DEFAULT_MAX_FILE_SIZE = 5000 * 1024 * 1024;
19 |
20 | export const MAX_FILE_SIZE = getEnvVar({
21 | name: "MAX_FILE_SIZE",
22 | parse: parseInt,
23 | defaultValue: DEFAULT_MAX_FILE_SIZE,
24 | });
25 |
26 | // Timeouts
27 | export const UPLOAD_TIMEOUT_MS = getEnvVar({
28 | name: "UPLOAD_TIMEOUT_MS",
29 | parse: parseInt,
30 | defaultValue: 10000, // 10 seconds
31 | });
32 |
33 | // File clearing interval in minutes, -1 to disable
34 | export const FILE_CLEAR_INTERVAL_MINS = getEnvVar({
35 | name: "FILE_CLEAR_INTERVAL_MINS",
36 | parse: parseInt,
37 | defaultValue: -1,
38 | });
39 |
40 | export const getConfig = query({
41 | args: {},
42 | handler: async () => {
43 | return {
44 | maxFileSize: MAX_FILE_SIZE,
45 | uploadTimeoutMs: UPLOAD_TIMEOUT_MS,
46 | };
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/components/FileGhostPreview.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Doc, Id } from "../../convex/_generated/dataModel";
3 | import { FileIcon } from "./FileIcon";
4 |
5 | type Position = { x: number; y: number };
6 | type RelativePosition = { id: Id<"files">; offsetX: number; offsetY: number };
7 |
8 | export const FileGhostPreview: React.FC<{
9 | file: Doc<"files">;
10 | dragPosition: Position;
11 | relativePositions: RelativePosition[];
12 | allSelectedFiles: Doc<"files">[];
13 | }> = ({ file, dragPosition, relativePositions, allSelectedFiles }) => (
14 | <>
15 |
24 | {relativePositions.map(({ id, offsetX, offsetY }) => {
25 | const relativeFile = allSelectedFiles.find((f) => f._id === id);
26 | if (!relativeFile) return null;
27 | return (
28 |
41 | );
42 | })}
43 | >
44 | );
45 |
--------------------------------------------------------------------------------
/src/components/MultiFileDownloadDialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Dialog,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "./ui/dialog";
10 | import { Button } from "./ui/button";
11 |
12 | type MultiFileDownloadDialogProps = {
13 | open: boolean;
14 | onOpenChange: (open: boolean) => void;
15 | onConfirm: () => void;
16 | fileCount: number;
17 | };
18 |
19 | export const MultiFileDownloadDialog: React.FC<
20 | MultiFileDownloadDialogProps
21 | > = ({ open, onOpenChange, onConfirm, fileCount }) => (
22 |
45 | );
46 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TogglePrimitive from "@radix-ui/react-toggle";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const toggleVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-transparent",
13 | outline:
14 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
15 | },
16 | size: {
17 | default: "h-9 px-3",
18 | sm: "h-8 px-2",
19 | lg: "h-10 px-3",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | size: "default",
25 | },
26 | },
27 | );
28 |
29 | const Toggle = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
39 | ));
40 |
41 | Toggle.displayName = TogglePrimitive.Root.displayName;
42 |
43 | export { Toggle, toggleVariants };
44 |
--------------------------------------------------------------------------------
/src/hooks/useFileCreator.ts:
--------------------------------------------------------------------------------
1 | import { useOptimisticCreateFile } from "./useOptimisticFiles";
2 | import { useFileUploader } from "./useFileUploader";
3 | import { toast } from "sonner";
4 | import { useQuery } from "convex/react";
5 | import { api } from "../../convex/_generated/api";
6 |
7 | export function useFileCreator() {
8 | const createFile = useOptimisticCreateFile();
9 | const { uploadFile } = useFileUploader();
10 | const config = useQuery(api.constants.getConfig);
11 |
12 | const createAndUploadFiles = async (
13 | files: File[],
14 | getPosition: (index: number) => { x: number; y: number },
15 | ) => {
16 | if (!config) return [];
17 |
18 | // Filter out files that are too large
19 | const validFiles = [];
20 | for (const file of files) {
21 | if (file.size > config.maxFileSize) {
22 | toast.error(
23 | `File ${file.name} exceeds maximum size of ${config.maxFileSize / 1024 / 1024}MB`,
24 | );
25 | continue;
26 | }
27 | validFiles.push(file);
28 | }
29 |
30 | if (validFiles.length === 0) return [];
31 |
32 | const fileInfos = validFiles.map((file, index) => ({
33 | name: file.name,
34 | size: file.size,
35 | type: file.type,
36 | position: getPosition(index),
37 | }));
38 |
39 | const fileIds = await createFile({ files: fileInfos });
40 |
41 | // Start uploads for each file
42 | for (let i = 0; i < validFiles.length; i++)
43 | void uploadFile(validFiles[i], fileIds[i]);
44 |
45 | return fileIds;
46 | };
47 |
48 | return { createAndUploadFiles };
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/DeleteFileDialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Dialog,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "./ui/dialog";
10 | import { Button } from "./ui/button";
11 |
12 | type DeleteFileDialogProps = {
13 | open: boolean;
14 | onOpenChange: (open: boolean) => void;
15 | onConfirm: () => void;
16 | fileCount: number;
17 | };
18 |
19 | export const DeleteFileDialog: React.FC = ({
20 | open,
21 | onOpenChange,
22 | onConfirm,
23 | fileCount,
24 | }) => (
25 |
51 | );
52 |
--------------------------------------------------------------------------------
/src/components/MainMenu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuItem,
6 | DropdownMenuTrigger,
7 | } from "./ui/dropdown-menu";
8 | import { Button } from "./ui/button";
9 | import { Menu, ExternalLink, Settings } from "lucide-react";
10 | import SettingsDialog from "./SettingsDialog";
11 | import { useSettings } from "../contexts/SettingsContext";
12 |
13 | export const MainMenu: React.FC = () => {
14 | const [settingsOpen, setSettingsOpen] = React.useState(false);
15 | const { settings } = useSettings();
16 |
17 | const handleDashboardClick = () => {
18 | window.open(settings.dashboardUrl, "_blank");
19 | };
20 |
21 | return (
22 | <>
23 |
24 |
25 |
32 |
33 |
34 |
35 |
36 | Open Dashboard
37 |
38 | setSettingsOpen(true)}>
39 |
40 | Settings
41 |
42 |
43 |
44 |
45 | >
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/hooks/useFileHandlers.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useDropzone } from "react-dropzone";
3 | import { useFileCreator } from "./useFileCreator";
4 |
5 | export const useFileHandlers = () => {
6 | const fileInputRef = React.useRef(null);
7 | const { createAndUploadFiles } = useFileCreator();
8 |
9 | const onDrop = React.useCallback(
10 | async (acceptedFiles: File[], event: React.DragEvent) => {
11 | const dropPosition = { x: event.pageX, y: event.pageY };
12 | await createAndUploadFiles(acceptedFiles, (index) => ({
13 | x: dropPosition.x + index * 20,
14 | y: dropPosition.y + index * 20,
15 | }));
16 | },
17 | [createAndUploadFiles],
18 | );
19 |
20 | const { getRootProps, isDragActive } = useDropzone({
21 | onDrop: (files, _, event) => {
22 | void onDrop(files, event as React.DragEvent);
23 | },
24 | noClick: true,
25 | });
26 |
27 | const handleSelectFilesClick = () => fileInputRef.current?.click();
28 |
29 | const handleFileInputChange = async (
30 | e: React.ChangeEvent,
31 | ) => {
32 | const files = Array.from(e.target.files || []);
33 | const centerX = window.innerWidth / 2;
34 | const centerY = window.innerHeight / 2;
35 |
36 | await createAndUploadFiles(files, (index) => ({
37 | x: centerX + index * 20,
38 | y: centerY + index * 20,
39 | }));
40 |
41 | if (fileInputRef.current) fileInputRef.current.value = "";
42 | };
43 |
44 | return {
45 | fileInputRef,
46 | getRootProps,
47 | isDragActive,
48 | handleSelectFilesClick,
49 | handleFileInputChange,
50 | };
51 | };
52 |
--------------------------------------------------------------------------------
/src/components/FileTypeIcon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Image,
4 | FileText,
5 | FileVideo,
6 | FileAudio,
7 | FileArchive,
8 | FilePlus,
9 | FileCode,
10 | } from "lucide-react";
11 |
12 | type FileTypeIconProps = {
13 | type: string;
14 | className?: string;
15 | };
16 |
17 | export const FileTypeIcon: React.FC = ({
18 | type,
19 | className = "",
20 | }) => {
21 | // Helper to get icon based on mime type or extension
22 | const getIconForType = (type: string) => {
23 | // Image files
24 | if (
25 | type.startsWith("image/") ||
26 | /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(type)
27 | )
28 | return ;
29 |
30 | // PDF files
31 | if (type === "application/pdf" || type.endsWith(".pdf"))
32 | return ;
33 |
34 | // Code files
35 | if (/\.(js|ts|jsx|tsx|html|css|json)$/i.test(type))
36 | return ;
37 |
38 | // Text files
39 | if (type.startsWith("text/") || /\.(txt|md)$/i.test(type))
40 | return ;
41 |
42 | // Video files
43 | if (
44 | type.startsWith("video/") ||
45 | /\.(mp4|mov|avi|wmv|flv|webm)$/i.test(type)
46 | )
47 | return ;
48 |
49 | // Audio files
50 | if (type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a)$/i.test(type))
51 | return ;
52 |
53 | // Archive files
54 | if (/\.(zip|rar|7z|tar|gz)$/i.test(type))
55 | return ;
56 |
57 | // Default file icon
58 | return ;
59 | };
60 |
61 | return getIconForType(type);
62 | };
63 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true, node: true },
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:@typescript-eslint/recommended-type-checked",
7 | "plugin:react-hooks/recommended",
8 | ],
9 | ignorePatterns: [
10 | "dist",
11 | ".eslintrc.cjs",
12 | "convex/_generated",
13 | "postcss.config.js",
14 | "tailwind.config.js",
15 | "vite.config.ts",
16 | // shadcn components by default violate some rules
17 | "src/components/ui",
18 | ],
19 | parser: "@typescript-eslint/parser",
20 | parserOptions: {
21 | EXPERIMENTAL_useProjectService: true,
22 | },
23 | plugins: ["react-refresh"],
24 | rules: {
25 | "react-refresh/only-export-components": [
26 | "warn",
27 | { allowConstantExport: true },
28 | ],
29 |
30 | // All of these overrides ease getting into
31 | // TypeScript, and can be removed for stricter
32 | // linting down the line.
33 |
34 | // Only warn on unused variables, and ignore variables starting with `_`
35 | "@typescript-eslint/no-unused-vars": [
36 | "warn",
37 | { varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
38 | ],
39 |
40 | // Allow escaping the compiler
41 | "@typescript-eslint/ban-ts-comment": "error",
42 |
43 | // Allow explicit `any`s
44 | "@typescript-eslint/no-explicit-any": "off",
45 |
46 | // START: Allow implicit `any`s
47 | "@typescript-eslint/no-unsafe-argument": "off",
48 | "@typescript-eslint/no-unsafe-assignment": "off",
49 | "@typescript-eslint/no-unsafe-call": "off",
50 | "@typescript-eslint/no-unsafe-member-access": "off",
51 | "@typescript-eslint/no-unsafe-return": "off",
52 | // END: Allow implicit `any`s
53 |
54 | // Allow async functions without await
55 | // for consistency (esp. Convex `handler`s)
56 | "@typescript-eslint/require-await": "off",
57 | },
58 | };
59 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | ourfiles-frontend:
3 | build:
4 | context: .
5 | args:
6 | - VITE_CONVEX_URL=http://${HOST_IP:-127.0.0.1}:${PORT:-3210}
7 | - VITE_CONVEX_DASHBOARD_URL=http://${HOST_IP:-127.0.0.1}:${DASHBOARD_PORT:-6791}
8 | restart: always
9 | ports:
10 | - "5173:80"
11 | environment:
12 | - VITE_CONVEX_URL=http://${HOST_IP:-127.0.0.1}:${PORT:-3210}
13 | - VITE_CONVEX_DASHBOARD_URL=http://${HOST_IP:-127.0.0.1}:${DASHBOARD_PORT:-6791}
14 |
15 | ourfiles-backend:
16 | build:
17 | context: .
18 | dockerfile: ./self-hosted/Dockerfile
19 | restart: always
20 | ports:
21 | - "${PORT:-3210}:3210"
22 | - "${SITE_PROXY_PORT:-3211}:3211"
23 | volumes:
24 | - data:/convex/data
25 | - ./convex:/convex/convex
26 |
27 | environment:
28 | - INSTANCE_NAME=${INSTANCE_NAME:-}
29 | - INSTANCE_SECRET=${INSTANCE_SECRET:-}
30 | - CONVEX_RELEASE_VERSION_DEV=${CONVEX_RELEASE_VERSION_DEV:-}
31 | - ACTIONS_USER_TIMEOUT_SECS=${ACTIONS_USER_TIMEOUT_SECS:-}
32 | - CONVEX_CLOUD_ORIGIN=${URL_BASE:-http://${HOST_IP:-127.0.0.1}}:${PORT:-3210}
33 | - CONVEX_SITE_ORIGIN=${URL_BASE:-http://${HOST_IP:-127.0.0.1}}:${SITE_PROXY_PORT:-3211}
34 | - DATABASE_URL=${DATABASE_URL:-}
35 | - DISABLE_BEACON=${DISABLE_BEACON:-}
36 | - REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-false}
37 | healthcheck:
38 | test: curl -f http://localhost:3210/version
39 | interval: 5s
40 | start_period: 5s
41 |
42 | ourfiles-dashboard:
43 | image: ghcr.io/get-convex/convex-dashboard:latest
44 | restart: always
45 | ports:
46 | - "${DASHBOARD_PORT:-6791}:6791"
47 | environment:
48 | - NEXT_PUBLIC_DEPLOYMENT_URL=http://${HOST_IP:-127.0.0.1}:${PORT:-3210}
49 | depends_on:
50 | ourfiles-backend:
51 | condition: service_healthy
52 |
53 | volumes:
54 | data:
55 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* To change the theme colors, change the values below
6 | or use the "Copy code" button at https://ui.shadcn.com/themes */
7 | @layer base {
8 | :root {
9 | --background: 0 0% 100%;
10 | --foreground: 20 14.3% 4.1%;
11 |
12 | --card: 0 0% 100%;
13 | --card-foreground: 20 14.3% 4.1%;
14 |
15 | --popover: 0 0% 100%;
16 | --popover-foreground: 20 14.3% 4.1%;
17 |
18 | --primary: 24 9.8% 10%;
19 | --primary-foreground: 60 9.1% 97.8%;
20 |
21 | --secondary: 60 4.8% 95.9%;
22 | --secondary-foreground: 24 9.8% 10%;
23 |
24 | --muted: 60 4.8% 95.9%;
25 | --muted-foreground: 25 5.3% 44.7%;
26 |
27 | --accent: 60 4.8% 95.9%;
28 | --accent-foreground: 24 9.8% 10%;
29 |
30 | --destructive: 0 84.2% 60.2%;
31 | --destructive-foreground: 60 9.1% 97.8%;
32 |
33 | --border: 20 5.9% 90%;
34 | --input: 20 5.9% 90%;
35 | --ring: 20 14.3% 4.1%;
36 |
37 | --radius: 0.5rem;
38 | }
39 |
40 | .dark {
41 | --background: 20 14.3% 4.1%;
42 | --foreground: 60 9.1% 97.8%;
43 |
44 | --card: 20 14.3% 4.1%;
45 | --card-foreground: 60 9.1% 97.8%;
46 |
47 | --popover: 20 14.3% 4.1%;
48 | --popover-foreground: 60 9.1% 97.8%;
49 |
50 | --primary: 60 9.1% 97.8%;
51 | --primary-foreground: 24 9.8% 10%;
52 |
53 | --secondary: 12 6.5% 15.1%;
54 | --secondary-foreground: 60 9.1% 97.8%;
55 |
56 | --muted: 12 6.5% 15.1%;
57 | --muted-foreground: 24 5.4% 63.9%;
58 |
59 | --accent: 12 6.5% 15.1%;
60 | --accent-foreground: 60 9.1% 97.8%;
61 |
62 | --destructive: 0 62.8% 30.6%;
63 | --destructive-foreground: 60 9.1% 97.8%;
64 |
65 | --border: 12 6.5% 15.1%;
66 | --input: 12 6.5% 15.1%;
67 | --ring: 24 5.7% 82.9%;
68 | }
69 | }
70 |
71 | @layer base {
72 | * {
73 | @apply border-border;
74 | }
75 | body {
76 | @apply bg-gray-50 text-foreground;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | DataModelFromSchemaDefinition,
13 | DocumentByName,
14 | TableNamesInDataModel,
15 | SystemTableNames,
16 | } from "convex/server";
17 | import type { GenericId } from "convex/values";
18 | import schema from "../schema.js";
19 |
20 | /**
21 | * The names of all of your Convex tables.
22 | */
23 | export type TableNames = TableNamesInDataModel;
24 |
25 | /**
26 | * The type of a document stored in Convex.
27 | *
28 | * @typeParam TableName - A string literal type of the table name (like "users").
29 | */
30 | export type Doc = DocumentByName<
31 | DataModel,
32 | TableName
33 | >;
34 |
35 | /**
36 | * An identifier for a document in Convex.
37 | *
38 | * Convex documents are uniquely identified by their `Id`, which is accessible
39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40 | *
41 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
42 | *
43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
44 | * strings when type checking.
45 | *
46 | * @typeParam TableName - A string literal type of the table name (like "users").
47 | */
48 | export type Id =
49 | GenericId;
50 |
51 | /**
52 | * A type describing your Convex data model.
53 | *
54 | * This type includes information about what tables you have, the type of
55 | * documents stored in those tables, and the indexes defined on them.
56 | *
57 | * This type is used to parameterize methods like `queryGeneric` and
58 | * `mutationGeneric` to make them type-safe.
59 | */
60 | export type DataModel = DataModelFromSchemaDefinition;
61 |
--------------------------------------------------------------------------------
/src/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
3 | import { VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { toggleVariants } from "@/components/ui/toggle";
7 |
8 | const ToggleGroupContext = React.createContext<
9 | VariantProps
10 | >({
11 | size: "default",
12 | variant: "default",
13 | });
14 |
15 | const ToggleGroup = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef &
18 | VariantProps
19 | >(({ className, variant, size, children, ...props }, ref) => (
20 |
25 |
26 | {children}
27 |
28 |
29 | ));
30 |
31 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
32 |
33 | const ToggleGroupItem = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef &
36 | VariantProps
37 | >(({ className, children, variant, size, ...props }, ref) => {
38 | const context = React.useContext(ToggleGroupContext);
39 |
40 | return (
41 |
52 | {children}
53 |
54 | );
55 | });
56 |
57 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
58 |
59 | export { ToggleGroup, ToggleGroupItem };
60 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center 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",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | },
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | },
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ourfiles",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "npm-run-all --parallel dev:frontend dev:backend dev:ts",
8 | "dev:self-host": "npm-run-all --parallel dev:frontend dev:backend-self-host dev:ts",
9 | "dev:frontend": "vite --open",
10 | "dev:ts": "tsc -b -w --preserveWatchOutput",
11 | "dev:backend": "convex dev --tail-logs",
12 | "dev:backend-self-host": "convex self-host dev --tail-logs",
13 | "predev": "convex dev --until-success && convex dashboard",
14 | "build": "tsc -b && vite build",
15 | "lint": "tsc && eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
16 | "preview": "vite preview"
17 | },
18 | "dependencies": {
19 | "@radix-ui/react-dialog": "^1.0.5",
20 | "@radix-ui/react-dropdown-menu": "^2.0.6",
21 | "@radix-ui/react-icons": "^1.3.0",
22 | "@radix-ui/react-slot": "^1.0.2",
23 | "@radix-ui/react-toast": "^1.2.6",
24 | "@radix-ui/react-toggle": "^1.0.3",
25 | "@radix-ui/react-toggle-group": "^1.0.4",
26 | "class-variance-authority": "^0.7.0",
27 | "clsx": "^2.1.1",
28 | "convex": "^1.19.1-alpha.0",
29 | "jszip": "^3.10.1",
30 | "lucide-react": "^0.474.0",
31 | "next-themes": "^0.3.0",
32 | "react": "^18.3.1",
33 | "react-dom": "^18.3.1",
34 | "react-dropzone": "^14.3.5",
35 | "sonner": "^1.7.4",
36 | "tailwind-merge": "^2.3.0",
37 | "tailwindcss-animate": "^1.0.7"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^20.14.9",
41 | "@types/react": "^18.3.3",
42 | "@types/react-dom": "^18.3.0",
43 | "@typescript-eslint/eslint-plugin": "^7.13.1",
44 | "@typescript-eslint/parser": "^7.13.1",
45 | "@vitejs/plugin-basic-ssl": "^1.2.0",
46 | "@vitejs/plugin-react": "^4.3.1",
47 | "autoprefixer": "^10.4.19",
48 | "eslint": "^8.57.0",
49 | "eslint-plugin-react-hooks": "^4.6.2",
50 | "eslint-plugin-react-refresh": "^0.4.7",
51 | "npm-run-all": "^4.1.5",
52 | "postcss": "^8.4.39",
53 | "prettier": "3.3.2",
54 | "tailwindcss": "^3.4.4",
55 | "typescript": "^5.2.2",
56 | "vite": "^5.3.1"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/src/contexts/SettingsContext.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | const storageKeys = {
4 | CONVEX_URL: "convexUrl",
5 | DASHBOARD_URL: "convexDashboardUrl",
6 | } as const;
7 |
8 | const envDefaults = {
9 | CONVEX_URL: import.meta.env.VITE_CONVEX_URL ?? "http://127.0.0.1:3210",
10 | DASHBOARD_URL: import.meta.env.VITE_CONVEX_DASHBOARD_URL,
11 | } as const;
12 |
13 | type Settings = {
14 | convexUrl: string;
15 | dashboardUrl: string;
16 | };
17 |
18 | type SettingsContextType = {
19 | settings: Settings;
20 | setSettings: (settings: Settings) => void;
21 | resetSettings: () => void;
22 | };
23 |
24 | const defaultSettings: Settings = {
25 | convexUrl: envDefaults.CONVEX_URL,
26 | dashboardUrl: envDefaults.DASHBOARD_URL,
27 | };
28 |
29 | const loadSettings = (): Settings => {
30 | const convexUrl = localStorage.getItem(storageKeys.CONVEX_URL);
31 | const dashboardUrl = localStorage.getItem(storageKeys.DASHBOARD_URL);
32 |
33 | return {
34 | convexUrl: convexUrl ?? envDefaults.CONVEX_URL,
35 | dashboardUrl: dashboardUrl ?? envDefaults.DASHBOARD_URL,
36 | };
37 | };
38 |
39 | const SettingsContext = React.createContext(null);
40 |
41 | export const useSettings = () => {
42 | const context = React.useContext(SettingsContext);
43 | if (!context)
44 | throw new Error("useSettings must be used within SettingsProvider");
45 | return context;
46 | };
47 |
48 | export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({
49 | children,
50 | }) => {
51 | const [settings, setSettingsState] = React.useState(loadSettings);
52 |
53 | const setSettings = (newSettings: Settings) => {
54 | localStorage.setItem(storageKeys.CONVEX_URL, newSettings.convexUrl);
55 | localStorage.setItem(storageKeys.DASHBOARD_URL, newSettings.dashboardUrl);
56 | setSettingsState(newSettings);
57 | window.location.reload();
58 | };
59 |
60 | const resetSettings = () => {
61 | localStorage.removeItem(storageKeys.CONVEX_URL);
62 | localStorage.removeItem(storageKeys.DASHBOARD_URL);
63 | setSettingsState(defaultSettings);
64 | window.location.reload();
65 | };
66 |
67 | return (
68 |
69 | {children}
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/SettingsDialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
3 | import { Button } from "./ui/button";
4 | import { Input } from "./ui/input";
5 | import { useSettings } from "../contexts/SettingsContext";
6 |
7 | const defaultConvexUrl = import.meta.env.VITE_CONVEX_URL;
8 | const defaultDashboardUrl = import.meta.env.VITE_CONVEX_DASHBOARD_URL;
9 |
10 | type SettingsDialogProps = {
11 | open: boolean;
12 | onOpenChange: (open: boolean) => void;
13 | };
14 |
15 | const SettingsDialog: React.FC = ({
16 | open,
17 | onOpenChange,
18 | }) => {
19 | const { settings, setSettings, resetSettings } = useSettings();
20 | const [convexUrl, setConvexUrl] = React.useState(settings.convexUrl);
21 | const [dashboardUrl, setDashboardUrl] = React.useState(settings.dashboardUrl);
22 |
23 | const handleSave = () => {
24 | setSettings({ convexUrl, dashboardUrl });
25 | onOpenChange(false);
26 | };
27 |
28 | const handleReset = () => {
29 | if (!confirm("Are you sure you want to reset to default settings?")) return;
30 | resetSettings();
31 | onOpenChange(false);
32 | };
33 |
34 | return (
35 |
68 | );
69 | };
70 |
71 | export default SettingsDialog;
72 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 |
5 | # Our Files
6 |
7 | A simple private file storage system powered by Convex.
8 |
9 | Simply drag and drop files onto the inteface to add files, then drag them out to download them.
10 |
11 | The project is built using React, Vite, Typescript, Convex, Tailwind
12 |
13 | # Running on Localhost
14 |
15 | This project is designed to work with [Convex self-hosted](https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md).
16 |
17 | The simplest way to get everything running together is:
18 |
19 | ```sh
20 | docker compose up
21 | ```
22 |
23 | Then open your browser to: http://localhost:5173/
24 |
25 | ## Convex Dashboard
26 |
27 | The dashboard is started as part of the docker compose, simply visit `http://localhost:6791`
28 |
29 | You will need an admin key, run the following command to generate one:
30 |
31 | ```bash
32 | docker compose exec ourfiles-backend ./generate_admin_key.sh
33 | ```
34 |
35 | ## Access from other devices on the LAN
36 |
37 | If you want to access the app from your devices you will need to set the "HOST_IP" environment variable before running docker compose. For example:
38 |
39 | ```sh
40 | bunx cross-env HOST_IP=192.168.1.165 docker compose up
41 | ```
42 |
43 | Where 192.168.1.165 is the LAN IP address for your machine. You should then be able to open http://192.168.1.165:5173 on another device on your LAN. Note: if you are on Windows you may need to allow access to port 5173 in your windows firewall.
44 |
45 | Most of the features should now work.
46 |
47 | There is one exception however, the multi-file drag out uses the [FileSystemAPI](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API). This API requires a "secure" context to work, so that means the entire app needs to be run over "https" instead of "http". This means you need to access it from `https://192.168.1.150:5173` not `http://` AND the backend needs to be running securely so that its websocket is `wss` not just `ws`
48 |
49 | I currently dont know what the best way of doing this is. Some have suggested using [tailscale serve](https://tailscale.com/kb/1242/tailscale-serve) others have suggested caddy or traefik to do this. Im not certain. If you have a way to solve this in a clean way I am very open to suggestions.
50 |
51 | # Development
52 |
53 | You can develop this like any other convex project, just run `bun dev` and go through the convex setup proceedures, this will then target convex cloud.
54 |
55 | If you want to target localhost via self-hosting then checkout the convex docs for help on that: https://docs.convex.dev/self-hosting
56 |
--------------------------------------------------------------------------------
/src/hooks/useOptimisticFiles.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery } from "convex/react";
2 | import { api } from "../../convex/_generated/api";
3 | import { Doc, Id } from "../../convex/_generated/dataModel";
4 |
5 | export function useOptimisticCreateFile() {
6 | return useMutation(api.files.create).withOptimisticUpdate(
7 | (localStore, args) => {
8 | const existingFiles = localStore.getQuery(api.files.list, {});
9 | if (existingFiles === undefined) return;
10 |
11 | const optimisticFiles: Doc<"files">[] = args.files.map((file) => ({
12 | _id: `${Math.random()}` as Id<"files">,
13 | _creationTime: Date.now(),
14 | uploadState: {
15 | kind: "created" as const,
16 | },
17 | ...file,
18 | }));
19 |
20 | localStore.setQuery(api.files.list, {}, [
21 | ...existingFiles,
22 | ...optimisticFiles,
23 | ]);
24 | },
25 | );
26 | }
27 |
28 | export function useOptimisticUpdateFilePosition() {
29 | return useMutation(api.files.updatePosition).withOptimisticUpdate(
30 | (localStore, args) => {
31 | const existingFiles = localStore.getQuery(api.files.list, {});
32 | if (existingFiles === undefined) return;
33 |
34 | const updatedFiles = existingFiles.map((file) =>
35 | file._id === args.id ? { ...file, position: args.position } : file,
36 | );
37 | localStore.setQuery(api.files.list, {}, updatedFiles);
38 | },
39 | );
40 | }
41 |
42 | export function useOptimisticRemoveFile() {
43 | return useMutation(api.files.remove).withOptimisticUpdate(
44 | (localStore, args) => {
45 | const existingFiles = localStore.getQuery(api.files.list, {});
46 | if (existingFiles === undefined) return;
47 |
48 | const updatedFiles = existingFiles.filter(
49 | (file) => !args.ids.includes(file._id),
50 | );
51 | localStore.setQuery(api.files.list, {}, updatedFiles);
52 | },
53 | );
54 | }
55 |
56 | export function useOptimisticUpdateFilePositions() {
57 | return useMutation(api.files.updatePositions).withOptimisticUpdate(
58 | (localStore, args) => {
59 | const existingFiles = localStore.getQuery(api.files.list, {});
60 | if (existingFiles === undefined) return;
61 |
62 | const updatedFiles = existingFiles.map((file) => {
63 | const update = args.updates.find((u) => u.id === file._id);
64 | return update ? { ...file, position: update.position } : file;
65 | });
66 | localStore.setQuery(api.files.list, {}, updatedFiles);
67 | },
68 | );
69 | }
70 |
71 | export function useFiles() {
72 | return useQuery(api.files.list) ?? [];
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/MultiSelectOverlay.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Doc } from "../../convex/_generated/dataModel";
3 | import { formatFileSize } from "../utils/formatters";
4 |
5 | type MultiSelectOverlayProps = {
6 | files: Doc<"files">[];
7 | };
8 |
9 | export const MultiSelectOverlay: React.FC = ({
10 | files,
11 | }) => {
12 | // Calculate bounding box of all selected files
13 | const bounds = React.useMemo(() => {
14 | if (files.length === 0) return null;
15 |
16 | let minX = Infinity;
17 | let minY = Infinity;
18 | let maxX = -Infinity;
19 | let maxY = -Infinity;
20 |
21 | const ICON_SIZE = 20; // Half of icon width/height
22 | const NAME_WIDTH = 50; // Half of max filename width (100px)
23 | const NAME_HEIGHT = 20; // Height of the filename text
24 | const PADDING = 10; // Padding around the selection
25 | const EXTRA_RIGHT_PADDING = 5; // Extra padding for the right side
26 |
27 | files.forEach((file) => {
28 | minX = Math.min(minX, file.position.x - ICON_SIZE);
29 | minY = Math.min(minY, file.position.y - ICON_SIZE);
30 | maxX = Math.max(maxX, file.position.x + NAME_WIDTH + EXTRA_RIGHT_PADDING);
31 | maxY = Math.max(maxY, file.position.y + ICON_SIZE + NAME_HEIGHT);
32 | });
33 |
34 | return {
35 | left: minX - PADDING,
36 | top: minY - PADDING,
37 | width: maxX - minX + PADDING * 2,
38 | height: maxY - minY + PADDING * 2,
39 | };
40 | }, [files]);
41 |
42 | if (!bounds || files.length <= 1) return null;
43 |
44 | const totalSize = files.reduce((sum, file) => sum + file.size, 0);
45 |
46 | return (
47 | <>
48 | {/* Dotted selection box */}
49 |
58 | {/* Multi-select tooltip */}
59 |
67 |
68 |
69 | {files.length} {files.length === 1 ? "file" : "files"} selected
70 |
71 |
72 | Total size: {formatFileSize(totalSize)}
73 |
74 |
75 |
76 | >
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here.
4 | See https://docs.convex.dev/functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result),
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: "selector",
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | safelist: ["dark"],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | sm: "1000px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | "popIn": {
71 | "0%": {
72 | opacity: "0",
73 | transform: "translate(-50%, -100%) scale(0.95)"
74 | },
75 | "50%": {
76 | opacity: "1",
77 | transform: "translate(-50%, -100%) scale(1.05)"
78 | },
79 | "100%": {
80 | opacity: "1",
81 | transform: "translate(-50%, -100%) scale(1)"
82 | }
83 | }
84 | },
85 | animation: {
86 | "accordion-down": "accordion-down 0.2s ease-out",
87 | "accordion-up": "accordion-up 0.2s ease-out",
88 | "popIn": "popIn 200ms cubic-bezier(.17,.67,.39,1.32)"
89 | },
90 | },
91 | },
92 | plugins: [require("tailwindcss-animate")],
93 | };
94 |
--------------------------------------------------------------------------------
/src/hooks/useSelectionBox.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Id } from "../../convex/_generated/dataModel";
3 |
4 | type Position = { x: number; y: number };
5 |
6 | export const useSelectionBox = ({
7 | files,
8 | onSelectionChange,
9 | }: {
10 | files: { _id: Id<"files">; position: Position }[];
11 | onSelectionChange: (selectedIds: Set>) => void;
12 | }) => {
13 | const [isDragSelecting, setIsDragSelecting] = React.useState(false);
14 | const [selectionStart, setSelectionStart] = React.useState({
15 | x: 0,
16 | y: 0,
17 | });
18 | const [selectionCurrent, setSelectionCurrent] = React.useState({
19 | x: 0,
20 | y: 0,
21 | });
22 |
23 | const handleMouseDown = (e: React.MouseEvent) => {
24 | if (e.button !== 0 || e.target !== e.currentTarget) return;
25 | setIsDragSelecting(true);
26 | setSelectionStart({ x: e.pageX, y: e.pageY });
27 | setSelectionCurrent({ x: e.pageX, y: e.pageY });
28 | onSelectionChange(new Set());
29 | };
30 |
31 | const updateSelection = (current: Position) => {
32 | // Calculate selection box bounds
33 | const left = Math.min(selectionStart.x, current.x);
34 | const right = Math.max(selectionStart.x, current.x);
35 | const top = Math.min(selectionStart.y, current.y);
36 | const bottom = Math.max(selectionStart.y, current.y);
37 |
38 | // Find files that intersect with the selection box
39 | const selectedIds = new Set>();
40 | for (const file of files) {
41 | const fileLeft = file.position.x - 20;
42 | const fileRight = file.position.x + 20;
43 | const fileTop = file.position.y - 20;
44 | const fileBottom = file.position.y + 20;
45 |
46 | if (
47 | fileLeft < right &&
48 | fileRight > left &&
49 | fileTop < bottom &&
50 | fileBottom > top
51 | )
52 | selectedIds.add(file._id);
53 | }
54 | onSelectionChange(selectedIds);
55 | };
56 |
57 | React.useEffect(() => {
58 | const handleGlobalMouseMove = (e: MouseEvent) => {
59 | if (!isDragSelecting) return;
60 |
61 | // Clamp coordinates to window boundaries
62 | const x = Math.max(0, Math.min(e.pageX, window.innerWidth));
63 | const y = Math.max(0, Math.min(e.pageY, window.innerHeight));
64 |
65 | setSelectionCurrent({ x, y });
66 | updateSelection({ x, y });
67 | };
68 |
69 | const handleGlobalMouseUp = () => setIsDragSelecting(false);
70 |
71 | if (isDragSelecting) {
72 | window.addEventListener("mousemove", handleGlobalMouseMove);
73 | window.addEventListener("mouseup", handleGlobalMouseUp);
74 | }
75 |
76 | return () => {
77 | window.removeEventListener("mousemove", handleGlobalMouseMove);
78 | window.removeEventListener("mouseup", handleGlobalMouseUp);
79 | };
80 | }, [isDragSelecting, selectionStart, files, updateSelection]);
81 |
82 | return {
83 | isDragSelecting,
84 | selectionStart,
85 | selectionCurrent,
86 | handleMouseDown,
87 | };
88 | };
89 |
--------------------------------------------------------------------------------
/src/hooks/useFileUploader.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "convex/react";
2 | import { api } from "../../convex/_generated/api";
3 | import { Id } from "../../convex/_generated/dataModel";
4 | import { toast } from "sonner";
5 |
6 | export function useFileUploader() {
7 | const generateUploadUrl = useMutation(api.files.generateUploadUrl);
8 | const startUpload = useMutation(api.files.startUpload);
9 | const updateUploadProgress = useMutation(api.files.updateUploadProgress);
10 | const completeUpload = useMutation(api.files.completeUpload);
11 | const setErrorState = useMutation(api.files.setErrorState);
12 |
13 | const getErrorMessage = (xhr: XMLHttpRequest): string => {
14 | try {
15 | const response = JSON.parse(xhr.responseText);
16 | if (response.message) return response.message;
17 | if (response.error) return response.error;
18 | } catch (e) {
19 | // If we can't parse the response, fall back to status text
20 | }
21 | return xhr.statusText || `Upload failed with status ${xhr.status}`;
22 | };
23 |
24 | const uploadFile = async (file: File, fileId: Id<"files">) => {
25 | try {
26 | // First mark the file as uploading
27 | await startUpload({ id: fileId });
28 |
29 | // Then generate upload URL and upload the file
30 | const uploadUrl = await generateUploadUrl();
31 |
32 | // Create a promise that resolves when the upload is complete
33 | await new Promise((resolve, reject) => {
34 | const xhr = new XMLHttpRequest();
35 | xhr.open("POST", uploadUrl);
36 | xhr.setRequestHeader(
37 | "Content-Type",
38 | file.type || "application/octet-stream",
39 | );
40 |
41 | let lastUpdate = 0;
42 | xhr.upload.onprogress = (event) => {
43 | if (event.lengthComputable) {
44 | const now = Date.now();
45 | if (now - lastUpdate >= 1000) {
46 | const progress = Math.round((event.loaded / event.total) * 100);
47 | void updateUploadProgress({ id: fileId, progress });
48 | lastUpdate = now;
49 | }
50 | }
51 | };
52 |
53 | xhr.onload = async () => {
54 | if (xhr.status === 200) {
55 | const { storageId } = JSON.parse(xhr.responseText);
56 | await completeUpload({ id: fileId, storageId });
57 | resolve(undefined);
58 | } else {
59 | reject(new Error(getErrorMessage(xhr)));
60 | }
61 | };
62 |
63 | xhr.onerror = () => {
64 | const message = getErrorMessage(xhr);
65 | reject(new Error(message || "Network error during upload"));
66 | };
67 | xhr.send(file);
68 | });
69 | } catch (error) {
70 | console.error("Upload failed:", error);
71 | const errorMessage =
72 | error instanceof Error ? error.message : "Unknown error occurred";
73 | await setErrorState({ id: fileId, message: errorMessage });
74 | toast.error(`Failed to upload ${file.name}`, {
75 | description: errorMessage,
76 | });
77 | }
78 | };
79 |
80 | return { uploadFile };
81 | }
82 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | actionGeneric,
13 | httpActionGeneric,
14 | queryGeneric,
15 | mutationGeneric,
16 | internalActionGeneric,
17 | internalMutationGeneric,
18 | internalQueryGeneric,
19 | } from "convex/server";
20 |
21 | /**
22 | * Define a query in this Convex app's public API.
23 | *
24 | * This function will be allowed to read your Convex database and will be accessible from the client.
25 | *
26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
28 | */
29 | export const query = queryGeneric;
30 |
31 | /**
32 | * Define a query that is only accessible from other Convex functions (but not from the client).
33 | *
34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
35 | *
36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38 | */
39 | export const internalQuery = internalQueryGeneric;
40 |
41 | /**
42 | * Define a mutation in this Convex app's public API.
43 | *
44 | * This function will be allowed to modify your Convex database and will be accessible from the client.
45 | *
46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
48 | */
49 | export const mutation = mutationGeneric;
50 |
51 | /**
52 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
53 | *
54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
55 | *
56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58 | */
59 | export const internalMutation = internalMutationGeneric;
60 |
61 | /**
62 | * Define an action in this Convex app's public API.
63 | *
64 | * An action is a function which can execute any JavaScript code, including non-deterministic
65 | * code and code with side-effects, like calling third-party services.
66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
68 | *
69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
71 | */
72 | export const action = actionGeneric;
73 |
74 | /**
75 | * Define an action that is only accessible from other Convex functions (but not from the client).
76 | *
77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
79 | */
80 | export const internalAction = internalActionGeneric;
81 |
82 | /**
83 | * Define a Convex HTTP action.
84 | *
85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
86 | * as its second.
87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
88 | */
89 | export const httpAction = httpActionGeneric;
90 |
--------------------------------------------------------------------------------
/src/components/UploadStatusIndicator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Doc } from "../../convex/_generated/dataModel";
3 |
4 | export const UploadStatusIndicator: React.FC<{ file: Doc<"files"> }> = ({
5 | file,
6 | }) => {
7 | const state = file.uploadState;
8 |
9 | const baseStyle: React.CSSProperties = {
10 | position: "absolute",
11 | top: -4,
12 | right: -4,
13 | width: 16,
14 | height: 16,
15 | borderRadius: 8,
16 | display: "flex",
17 | alignItems: "center",
18 | justifyContent: "center",
19 | border: "1.5px solid white",
20 | boxShadow: "0 0 0 1px rgba(0,0,0,0.1)",
21 | };
22 |
23 | if (state.kind === "created")
24 | return (
25 |
46 | );
47 |
48 | if (state.kind === "uploading")
49 | return (
50 |
84 | );
85 |
86 | if (state.kind === "errored")
87 | return (
88 |
109 | );
110 |
111 | if (state.kind === "uploaded")
112 | return (
113 |
135 | );
136 |
137 | // TypeScript will ensure we've handled all cases
138 | const _exhaustiveCheck: never = state;
139 | return null;
140 | };
141 |
--------------------------------------------------------------------------------
/src/hooks/useDragPosition.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Id } from "../../convex/_generated/dataModel";
3 |
4 | type Position = { x: number; y: number };
5 | type FileUpdate = { id: Id<"files">; position: Position };
6 | type RelativePosition = { id: Id<"files">; offsetX: number; offsetY: number };
7 |
8 | export const useDragPosition = ({
9 | basePosition,
10 | baseId,
11 | relativeFiles,
12 | onDragEnd,
13 | }: {
14 | basePosition: Position;
15 | baseId: Id<"files">;
16 | relativeFiles: { id: Id<"files">; position: Position }[];
17 | onDragEnd: (updates: FileUpdate[]) => void;
18 | }) => {
19 | const [dragPosition, setDragPosition] = React.useState({
20 | x: 0,
21 | y: 0,
22 | });
23 | const [mouseOffset, setMouseOffset] = React.useState({
24 | x: 0,
25 | y: 0,
26 | });
27 | const [isDragging, setIsDragging] = React.useState(false);
28 |
29 | // Calculate relative positions of all files to the dragged file
30 | const relativePositions = React.useMemo(() => {
31 | if (relativeFiles.length === 0) return [];
32 | return relativeFiles
33 | .filter((f) => f.id !== baseId)
34 | .map((f) => ({
35 | id: f.id,
36 | offsetX: f.position.x - basePosition.x,
37 | offsetY: f.position.y - basePosition.y,
38 | }));
39 | }, [relativeFiles, basePosition, baseId]);
40 |
41 | const handleDragStart = (e: React.DragEvent | React.TouchEvent) => {
42 | setIsDragging(true);
43 | const rect = (e.target as HTMLElement).getBoundingClientRect();
44 | const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
45 | const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
46 |
47 | const offsetX = clientX - rect.left;
48 | const offsetY = clientY - rect.top;
49 | setMouseOffset({ x: offsetX, y: offsetY });
50 |
51 | const newPosition = {
52 | x: clientX - offsetX + 20,
53 | y: clientY - offsetY + 20,
54 | };
55 | setDragPosition(newPosition);
56 |
57 | if ("dataTransfer" in e) {
58 | const dragImage = new Image();
59 | dragImage.src =
60 | "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
61 | e.dataTransfer.setDragImage(dragImage, 0, 0);
62 | }
63 | };
64 |
65 | const handleDrag = (e: React.DragEvent | React.TouchEvent) => {
66 | if (!isDragging) return;
67 |
68 | const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
69 | const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
70 |
71 | // Skip if no coordinates (can happen during drag)
72 | if (!clientX || !clientY) return;
73 |
74 | const newPosition = {
75 | x: clientX - mouseOffset.x + 20,
76 | y: clientY - mouseOffset.y + 20,
77 | };
78 | setDragPosition(newPosition);
79 | };
80 |
81 | const handleDragEnd = (e: React.DragEvent | React.TouchEvent) => {
82 | setIsDragging(false);
83 |
84 | const clientX =
85 | "changedTouches" in e ? e.changedTouches[0].clientX : e.clientX;
86 | const clientY =
87 | "changedTouches" in e ? e.changedTouches[0].clientY : e.clientY;
88 |
89 | // If dropped inside the window, update positions
90 | if (
91 | clientX > 0 &&
92 | clientY > 0 &&
93 | clientX < window.innerWidth &&
94 | clientY < window.innerHeight
95 | ) {
96 | const newPosition = {
97 | x: clientX - mouseOffset.x + 20,
98 | y: clientY - mouseOffset.y + 20,
99 | };
100 |
101 | // Create updates for all selected files
102 | const updates: FileUpdate[] = [
103 | { id: baseId, position: newPosition },
104 | ...relativePositions.map(({ id, offsetX, offsetY }) => ({
105 | id,
106 | position: {
107 | x: newPosition.x + offsetX,
108 | y: newPosition.y + offsetY,
109 | },
110 | })),
111 | ];
112 | onDragEnd(updates);
113 | }
114 | };
115 |
116 | return {
117 | dragPosition,
118 | isDragging,
119 | handleDragStart,
120 | handleDrag,
121 | handleDragEnd,
122 | relativePositions,
123 | };
124 | };
125 |
--------------------------------------------------------------------------------
/src/components/SelectedItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { FileIcon } from "./FileIcon";
3 | import { FileTooltip } from "./FileTooltip";
4 | import { Doc, Id } from "../../convex/_generated/dataModel";
5 | import { useFileDownloadDrag } from "../hooks/useFileDownloadDrag";
6 | import { MultiFileDownloadDialog } from "./MultiFileDownloadDialog";
7 | import { toast } from "sonner";
8 | import { useDragPosition } from "../hooks/useDragPosition";
9 | import { FileGhostPreview } from "./FileGhostPreview";
10 | import useIsMobile from "@/hooks/useIsMobile";
11 |
12 | type SelectedItemProps = {
13 | file: Doc<"files">;
14 | allSelectedFiles: Doc<"files">[];
15 | onDragEnd: (
16 | updates: { id: Id<"files">; position: { x: number; y: number } }[],
17 | ) => void;
18 | onDelete?: () => void;
19 | onClick: (e: React.MouseEvent) => void;
20 | disableTooltip?: boolean;
21 | };
22 |
23 | export const SelectedItem: React.FC = ({
24 | file,
25 | allSelectedFiles,
26 | onDragEnd,
27 | onDelete,
28 | disableTooltip,
29 | onClick,
30 | }) => {
31 | const {
32 | isDragging: isExternalDragging,
33 | handleDragStart: handleExternalDragStart,
34 | handleDragEnd: handleExternalDragEnd,
35 | canDownload,
36 | showDownloadDialog,
37 | setShowDownloadDialog,
38 | downloadMultipleFiles,
39 | fileCount,
40 | } = useFileDownloadDrag({
41 | files: allSelectedFiles.length > 1 ? allSelectedFiles : [file],
42 | singleFile: allSelectedFiles.length === 1,
43 | });
44 |
45 | const {
46 | dragPosition,
47 | isDragging: isInternalDragging,
48 | handleDragStart,
49 | handleDrag,
50 | handleDragEnd,
51 | relativePositions,
52 | } = useDragPosition({
53 | basePosition: file.position,
54 | baseId: file._id,
55 | relativeFiles: allSelectedFiles.map((f) => ({
56 | id: f._id,
57 | position: f.position,
58 | })),
59 | onDragEnd,
60 | });
61 |
62 | const handleDragStartWrapper = (e: React.DragEvent) => {
63 | handleDragStart(e);
64 | if (canDownload) handleExternalDragStart(e);
65 | };
66 |
67 | const handleDragEndWrapper = (e: React.DragEvent) => {
68 | handleDragEnd(e);
69 |
70 | // Only handle external drag end when dropping outside the window
71 | if (
72 | e.clientX <= 0 ||
73 | e.clientY <= 0 ||
74 | e.clientX >= window.innerWidth ||
75 | e.clientY >= window.innerHeight
76 | ) {
77 | handleExternalDragEnd();
78 | if (allSelectedFiles.length === 1)
79 | toast.success(`Downloaded ${file.name}`);
80 | }
81 | };
82 |
83 | const isMobile = useIsMobile();
84 | const isTooltipOpen =
85 | !disableTooltip && !isExternalDragging && !isInternalDragging;
86 | const isDragDisabled = isMobile && !isTooltipOpen;
87 |
88 | return (
89 | <>
90 |
113 | ) : undefined
114 | }
115 | />
116 |
117 | {isInternalDragging && (
118 |
124 | )}
125 |
126 | void downloadMultipleFiles()}
130 | fileCount={fileCount}
131 | />
132 | >
133 | );
134 | };
135 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DialogPrimitive from "@radix-ui/react-dialog";
3 | import { Cross2Icon } from "@radix-ui/react-icons";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const Dialog = DialogPrimitive.Root;
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger;
10 |
11 | const DialogPortal = DialogPrimitive.Portal;
12 |
13 | const DialogClose = DialogPrimitive.Close;
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ));
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | );
66 | DialogHeader.displayName = "DialogHeader";
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | );
80 | DialogFooter.displayName = "DialogFooter";
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ));
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ));
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogTrigger,
114 | DialogClose,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | };
121 |
--------------------------------------------------------------------------------
/src/hooks/useFileDownloadDrag.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Doc } from "../../convex/_generated/dataModel";
3 | import { toast } from "sonner";
4 |
5 | // Add type declarations for the File System Access API
6 | declare global {
7 | interface Window {
8 | showDirectoryPicker(options?: {
9 | mode?: "read" | "readwrite";
10 | }): Promise;
11 | }
12 | }
13 |
14 | type UseFileDownloadDragProps = {
15 | files: Doc<"files">[];
16 | singleFile?: boolean;
17 | };
18 |
19 | export function useFileDownloadDrag({
20 | files,
21 | singleFile,
22 | }: UseFileDownloadDragProps) {
23 | const [isDragging, setIsDragging] = useState(false);
24 | const [showDownloadDialog, setShowDownloadDialog] = useState(false);
25 |
26 | // Only use uploaded files
27 | const uploadedFiles = files.filter((f) => f.uploadState.kind === "uploaded");
28 |
29 | const downloadMultipleFiles = async () => {
30 | try {
31 | // Create a directory handle
32 | const dirHandle = await window.showDirectoryPicker({
33 | mode: "readwrite",
34 | });
35 |
36 | const toastId = toast.loading(
37 | `Downloading ${uploadedFiles.length} files...`,
38 | {
39 | description: "Starting download...",
40 | },
41 | );
42 |
43 | let completedFiles = 0;
44 |
45 | // Download each file and write it to the directory
46 | await Promise.all(
47 | uploadedFiles.map(async (file) => {
48 | if (file.uploadState.kind !== "uploaded") return;
49 |
50 | try {
51 | const response = await fetch(file.uploadState.url);
52 | const blob = await response.blob();
53 |
54 | const fileHandle = await dirHandle.getFileHandle(file.name, {
55 | create: true,
56 | });
57 | const writable = await fileHandle.createWritable();
58 | await writable.write(blob);
59 | await writable.close();
60 |
61 | completedFiles++;
62 | toast.loading(`Downloading ${uploadedFiles.length} files...`, {
63 | id: toastId,
64 | description: `Downloaded ${completedFiles} of ${uploadedFiles.length} files`,
65 | });
66 | } catch (error) {
67 | console.error(`Failed to download ${file.name}:`, error);
68 | toast.error(`Failed to download ${file.name}`);
69 | }
70 | }),
71 | );
72 |
73 | toast.success(`Downloaded ${completedFiles} files`, {
74 | id: toastId,
75 | description:
76 | completedFiles === uploadedFiles.length
77 | ? "All files downloaded successfully"
78 | : `${uploadedFiles.length - completedFiles} files failed to download`,
79 | });
80 | } catch (error: unknown) {
81 | if (error instanceof Error && error.name !== "AbortError") {
82 | console.error("Failed to save files:", error);
83 | toast.error("Failed to download files", {
84 | description: error.message,
85 | });
86 | }
87 | }
88 | setShowDownloadDialog(false);
89 | };
90 |
91 | // Set up drag leave handler
92 | useEffect(() => {
93 | if (!isDragging || singleFile || uploadedFiles.length <= 1) return;
94 |
95 | const handleDragLeave = (e: DragEvent) => {
96 | // Only handle if actually leaving the window
97 | if (e.relatedTarget === null) {
98 | setShowDownloadDialog(true);
99 | }
100 | };
101 |
102 | window.addEventListener("dragleave", handleDragLeave);
103 | return () => window.removeEventListener("dragleave", handleDragLeave);
104 | }, [isDragging, singleFile, uploadedFiles.length]);
105 |
106 | const handleDragStart = (e: React.DragEvent) => {
107 | setIsDragging(true);
108 | if (!uploadedFiles.length) return;
109 |
110 | // If single file or only one file is selected, use simple format
111 | if (singleFile || uploadedFiles.length === 1) {
112 | const file = uploadedFiles[0];
113 | if (file.uploadState.kind === "uploaded") {
114 | e.dataTransfer.effectAllowed = "copy";
115 | e.dataTransfer.setData(
116 | "DownloadURL",
117 | `${file.type}:${file.name}:${file.uploadState.url}`,
118 | );
119 | }
120 | return;
121 | }
122 |
123 | // For multiple files, just set the effect
124 | e.dataTransfer.effectAllowed = "copy";
125 | e.dataTransfer.setData("text/plain", "multiple files");
126 | };
127 |
128 | const handleDragEnd = () => {
129 | setIsDragging(false);
130 | };
131 |
132 | return {
133 | isDragging,
134 | handleDragStart,
135 | handleDragEnd,
136 | showDownloadDialog,
137 | setShowDownloadDialog,
138 | downloadMultipleFiles,
139 | fileCount: uploadedFiles.length,
140 | canDownload: uploadedFiles.length > 0,
141 | };
142 | }
143 |
--------------------------------------------------------------------------------
/src/components/FileIcon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Doc } from "../../convex/_generated/dataModel";
3 | import { UploadStatusIndicator } from "./UploadStatusIndicator";
4 | import { FileTypeIcon } from "./FileTypeIcon";
5 |
6 | type FileIconProps = {
7 | file: Doc<"files">;
8 | isSelected: boolean;
9 | onClick?: (e: React.MouseEvent) => void;
10 | onMouseDown?: (e: React.MouseEvent) => void;
11 | style?: React.CSSProperties;
12 | onDragStart?: (e: React.DragEvent) => void;
13 | onDrag?: (e: React.DragEvent) => void;
14 | onDragEnd?: (e: React.DragEvent) => void;
15 | onTouchStart?: (e: React.TouchEvent) => void;
16 | onTouchMove?: (e: React.TouchEvent) => void;
17 | onTouchEnd?: (e: React.TouchEvent) => void;
18 | tooltip?: React.ReactNode;
19 | draggable?: boolean;
20 | animate?: boolean;
21 | disableTooltip?: boolean;
22 | };
23 |
24 | export const FileIcon: React.FC = ({
25 | file,
26 | isSelected,
27 | onClick,
28 | onMouseDown,
29 | style,
30 | onDragStart,
31 | onDrag,
32 | onDragEnd,
33 | onTouchStart,
34 | onTouchMove,
35 | onTouchEnd,
36 | tooltip,
37 | draggable,
38 | animate = false,
39 | disableTooltip,
40 | }) => {
41 | const [isDragging, setIsDragging] = React.useState(false);
42 |
43 | const handleDoubleClick = (e: React.MouseEvent) => {
44 | e.stopPropagation();
45 | if (file.uploadState.kind === "uploaded")
46 | window.open(file.uploadState.url, "_blank");
47 | };
48 |
49 | // Don't apply transitions if this is a ghost preview (has pointer-events: none)
50 | const isGhostPreview = style?.pointerEvents === "none";
51 |
52 | const left = file.position.x - 20;
53 | const top = file.position.y - 20;
54 |
55 | return (
56 | <>
57 | {
72 | setIsDragging(true);
73 | onDragStart?.(e);
74 | }
75 | : undefined
76 | }
77 | onDrag={onDrag}
78 | onDragEnd={
79 | onDragEnd
80 | ? (e: React.DragEvent) => {
81 | setIsDragging(false);
82 | onDragEnd?.(e);
83 | }
84 | : undefined
85 | }
86 | onTouchStart={onTouchStart}
87 | onTouchMove={onTouchMove}
88 | onTouchEnd={onTouchEnd}
89 | >
90 |
91 |
101 |
105 | {(file.uploadState.kind === "uploading" ||
106 | file.uploadState.kind === "errored") && (
107 |
120 | )}
121 |
122 |
123 |
124 |
130 | {file.name}
131 |
132 |
133 | {!disableTooltip && tooltip && (
134 |
141 | {tooltip}
142 |
143 | )}
144 | >
145 | );
146 | };
147 |
--------------------------------------------------------------------------------
/src/components/FileUpload.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { SelectedItem } from "./SelectedItem";
3 | import { UnselectedItem } from "./UnselectedItem";
4 | import { AddItemsButton } from "./SelectFilesButton";
5 | import { DropZoneOverlay } from "./DropZoneOverlay";
6 | import { EmptyState } from "./EmptyState";
7 | import { SelectionBox } from "./SelectionBox";
8 | import { Id } from "../../convex/_generated/dataModel";
9 | import { TopRightItems } from "./TopRightItems";
10 | import { MultiSelectOverlay } from "./MultiSelectOverlay";
11 | import { DeleteFileDialog } from "./DeleteFileDialog";
12 | import { Toaster } from "./ui/toast";
13 | import { useQuery } from "convex/react";
14 | import { api } from "../../convex/_generated/api";
15 | import {
16 | useOptimisticUpdateFilePositions,
17 | useOptimisticRemoveFile,
18 | } from "../hooks/useOptimisticFiles";
19 | import { useSelectionBox } from "../hooks/useSelectionBox";
20 | import { useFileHandlers } from "../hooks/useFileHandlers";
21 | import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
22 |
23 | export const FileUpload: React.FC = () => {
24 | const [selectedFileIds, setSelectedFileIds] = React.useState<
25 | Set>
26 | >(new Set());
27 | const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
28 | const containerRef = React.useRef(null);
29 |
30 | const files = useQuery(api.files.list) ?? [];
31 | const updateFilePositions = useOptimisticUpdateFilePositions();
32 | const removeFile = useOptimisticRemoveFile();
33 |
34 | const hasFiles = files.length > 0;
35 | const unselectedFiles = files.filter(
36 | (file) => !selectedFileIds.has(file._id),
37 | );
38 | const selectedFiles = files.filter((file) => selectedFileIds.has(file._id));
39 |
40 | const {
41 | fileInputRef,
42 | getRootProps,
43 | isDragActive,
44 | handleSelectFilesClick,
45 | handleFileInputChange,
46 | } = useFileHandlers();
47 |
48 | const { isDragSelecting, selectionStart, selectionCurrent, handleMouseDown } =
49 | useSelectionBox({
50 | files,
51 | onSelectionChange: setSelectedFileIds,
52 | });
53 |
54 | useKeyboardShortcuts({
55 | selectedIds: selectedFileIds,
56 | onDelete: () => setShowDeleteConfirm(true),
57 | onClearSelection: () => setSelectedFileIds(new Set()),
58 | });
59 |
60 | const handleFileClick = (fileId: Id<"files">, e: React.MouseEvent) => {
61 | e.stopPropagation();
62 | if (e.shiftKey) {
63 | const newSelection = new Set(selectedFileIds);
64 | if (newSelection.has(fileId)) newSelection.delete(fileId);
65 | else newSelection.add(fileId);
66 | setSelectedFileIds(newSelection);
67 | } else setSelectedFileIds(new Set([fileId]));
68 | };
69 |
70 | const handleConfirmDelete = () => {
71 | removeFile({ ids: Array.from(selectedFileIds) });
72 | setSelectedFileIds(new Set());
73 | setShowDeleteConfirm(false);
74 | };
75 |
76 | const handleDashboardClick = () =>
77 | window.open(import.meta.env.VITE_CONVEX_DASHBOARD_URL, "_blank");
78 |
79 | React.useEffect(() => {
80 | // Prevent page scrolling when dragging files
81 | const preventScroll = (e: TouchEvent) => {
82 | if (e.target instanceof Element && e.target.closest(".file-icon"))
83 | e.preventDefault();
84 | };
85 |
86 | document.addEventListener("touchmove", preventScroll, { passive: false });
87 | return () => document.removeEventListener("touchmove", preventScroll);
88 | }, []);
89 |
90 | return (
91 | <>
92 |
102 |
103 |
104 |
void handleFileInputChange(e)}
110 | />
111 |
112 | {unselectedFiles.map((file) => (
113 |
handleFileClick(file._id, e)}
117 | isDragSelecting={isDragSelecting}
118 | />
119 | ))}
120 |
121 | {selectedFiles.map((file) => (
122 | void updateFilePositions({ updates })}
127 | onDelete={() => setSelectedFileIds(new Set())}
128 | onClick={(e) => handleFileClick(file._id, e)}
129 | disableTooltip={selectedFiles.length > 1 || isDragSelecting}
130 | />
131 | ))}
132 |
133 | {selectedFiles.length > 1 && (
134 |
135 | )}
136 |
137 |
138 |
139 | {!hasFiles && }
140 |
141 | {isDragActive && }
142 |
143 | {isDragSelecting && (
144 |
145 | )}
146 |
147 |
153 |
154 |
155 | >
156 | );
157 | };
158 |
--------------------------------------------------------------------------------
/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | ActionBuilder,
13 | HttpActionBuilder,
14 | MutationBuilder,
15 | QueryBuilder,
16 | GenericActionCtx,
17 | GenericMutationCtx,
18 | GenericQueryCtx,
19 | GenericDatabaseReader,
20 | GenericDatabaseWriter,
21 | } from "convex/server";
22 | import type { DataModel } from "./dataModel.js";
23 |
24 | /**
25 | * Define a query in this Convex app's public API.
26 | *
27 | * This function will be allowed to read your Convex database and will be accessible from the client.
28 | *
29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
31 | */
32 | export declare const query: QueryBuilder;
33 |
34 | /**
35 | * Define a query that is only accessible from other Convex functions (but not from the client).
36 | *
37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
38 | *
39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
41 | */
42 | export declare const internalQuery: QueryBuilder;
43 |
44 | /**
45 | * Define a mutation in this Convex app's public API.
46 | *
47 | * This function will be allowed to modify your Convex database and will be accessible from the client.
48 | *
49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
51 | */
52 | export declare const mutation: MutationBuilder;
53 |
54 | /**
55 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
56 | *
57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
58 | *
59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
61 | */
62 | export declare const internalMutation: MutationBuilder;
63 |
64 | /**
65 | * Define an action in this Convex app's public API.
66 | *
67 | * An action is a function which can execute any JavaScript code, including non-deterministic
68 | * code and code with side-effects, like calling third-party services.
69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
71 | *
72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
74 | */
75 | export declare const action: ActionBuilder;
76 |
77 | /**
78 | * Define an action that is only accessible from other Convex functions (but not from the client).
79 | *
80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
82 | */
83 | export declare const internalAction: ActionBuilder;
84 |
85 | /**
86 | * Define an HTTP action.
87 | *
88 | * This function will be used to respond to HTTP requests received by a Convex
89 | * deployment if the requests matches the path and method where this action
90 | * is routed. Be sure to route your action in `convex/http.js`.
91 | *
92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
93 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
94 | */
95 | export declare const httpAction: HttpActionBuilder;
96 |
97 | /**
98 | * A set of services for use within Convex query functions.
99 | *
100 | * The query context is passed as the first argument to any Convex query
101 | * function run on the server.
102 | *
103 | * This differs from the {@link MutationCtx} because all of the services are
104 | * read-only.
105 | */
106 | export type QueryCtx = GenericQueryCtx;
107 |
108 | /**
109 | * A set of services for use within Convex mutation functions.
110 | *
111 | * The mutation context is passed as the first argument to any Convex mutation
112 | * function run on the server.
113 | */
114 | export type MutationCtx = GenericMutationCtx;
115 |
116 | /**
117 | * A set of services for use within Convex action functions.
118 | *
119 | * The action context is passed as the first argument to any Convex action
120 | * function run on the server.
121 | */
122 | export type ActionCtx = GenericActionCtx;
123 |
124 | /**
125 | * An interface to read from the database within Convex query functions.
126 | *
127 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
128 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
129 | * building a query.
130 | */
131 | export type DatabaseReader = GenericDatabaseReader;
132 |
133 | /**
134 | * An interface to read from and write to the database within Convex mutation
135 | * functions.
136 | *
137 | * Convex guarantees that all writes within a single mutation are
138 | * executed atomically, so you never have to worry about partial writes leaving
139 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
140 | * for the guarantees Convex provides your functions.
141 | */
142 | export type DatabaseWriter = GenericDatabaseWriter;
143 |
--------------------------------------------------------------------------------
/src/components/FileTooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Button } from "./ui/button";
3 | import { Trash2, Upload, Check, Clock, Download, XCircle } from "lucide-react";
4 | import { Doc, Id } from "../../convex/_generated/dataModel";
5 | import { useOptimisticRemoveFile } from "../hooks/useOptimisticFiles";
6 | import { DeleteFileDialog } from "./DeleteFileDialog";
7 | import { formatFileSize } from "../utils/formatters";
8 |
9 | type FilePreviewProps = {
10 | type: string;
11 | url?: string;
12 | };
13 |
14 | const FilePreview: React.FC = ({ type, url }) => {
15 | if (!url) return null;
16 |
17 | // Image files
18 | if (type.startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(type))
19 | return (
20 |
21 |

26 |
27 | );
28 |
29 | // Video files
30 | if (type.startsWith("video/") || /\.(mp4|mov|avi|wmv|flv|webm)$/i.test(type))
31 | return (
32 |
33 |
34 |
35 | );
36 |
37 | // Audio files
38 | if (type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a)$/i.test(type))
39 | return (
40 |
43 | );
44 |
45 | // PDF files - show iframe preview
46 | if (type === "application/pdf" || type.endsWith(".pdf"))
47 | return (
48 |
49 |
50 |
51 | );
52 |
53 | // Text/Code files - could add a code preview here but might be better as a separate feature
54 | return null;
55 | };
56 |
57 | type FileTooltipProps = {
58 | fileId: Id<"files">;
59 | name: string;
60 | size: number;
61 | type: string;
62 | onDelete?: () => void;
63 | uploadState: Doc<"files">["uploadState"];
64 | };
65 |
66 | export const FileTooltip: React.FC = ({
67 | fileId,
68 | name,
69 | size,
70 | type,
71 | onDelete,
72 | uploadState,
73 | }) => {
74 | const removeFile = useOptimisticRemoveFile();
75 | const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
76 |
77 | const handleDelete = () => {
78 | void removeFile({ ids: [fileId] });
79 | setShowDeleteDialog(false);
80 | onDelete?.();
81 | };
82 |
83 | const handleDownload = () => {
84 | if (uploadState.kind === "uploaded") {
85 | window.open(uploadState.url, "_blank");
86 | }
87 | };
88 |
89 | const getUploadStateDisplay = () => {
90 | switch (uploadState.kind) {
91 | case "created":
92 | return (
93 |
94 |
95 | Waiting
96 |
97 | );
98 | case "uploading":
99 | return (
100 |
101 |
102 | {uploadState.progress}%
103 |
104 | );
105 | case "errored":
106 | return (
107 |
108 |
109 | Error: {uploadState.message}
110 |
111 | );
112 | case "uploaded":
113 | return (
114 |
115 |
116 | Uploaded
117 |
118 | );
119 | }
120 | };
121 |
122 | return (
123 | <>
124 |
125 |
126 |
127 | {uploadState.kind === "uploaded" && (
128 |
129 | )}
130 |
131 |
132 |
133 | PROPERTIES
134 |
135 |
136 | Size: {formatFileSize(size)}
137 |
138 |
Type: {type || "Unknown"}
139 |
140 |
141 |
142 |
143 | STATE
144 |
145 |
{getUploadStateDisplay()}
146 |
147 |
148 |
149 |
150 |
159 |
170 |
171 |
172 |
173 | {/* Triangle pointer */}
174 |
182 |
183 |
184 |
190 | >
191 | );
192 | };
193 |
--------------------------------------------------------------------------------
/convex/files.ts:
--------------------------------------------------------------------------------
1 | import { mutation, query, internalMutation } from "./_generated/server";
2 | import { v } from "convex/values";
3 | import { ConvexError } from "convex/values";
4 | import { Doc, Id } from "./_generated/dataModel";
5 | import { internal } from "./_generated/api";
6 | import { MAX_FILE_SIZE, UPLOAD_TIMEOUT_MS } from "./constants";
7 |
8 | export const list = query({
9 | args: {},
10 | handler: async (ctx) => {
11 | return await ctx.db.query("files").collect();
12 | },
13 | });
14 |
15 | export const generateUploadUrl = mutation({
16 | args: {},
17 | handler: async (ctx) => {
18 | return await ctx.storage.generateUploadUrl();
19 | },
20 | });
21 |
22 | export const create = mutation({
23 | args: {
24 | files: v.array(
25 | v.object({
26 | name: v.string(),
27 | size: v.number(),
28 | type: v.string(),
29 | position: v.object({
30 | x: v.number(),
31 | y: v.number(),
32 | }),
33 | }),
34 | ),
35 | },
36 | handler: async (ctx, { files }) => {
37 | const fileIds = [];
38 | for (const file of files) {
39 | if (file.size > MAX_FILE_SIZE)
40 | throw new ConvexError(
41 | `File ${file.name} exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`,
42 | );
43 |
44 | const id = await ctx.db.insert("files", {
45 | ...file,
46 | uploadState: {
47 | kind: "created",
48 | },
49 | });
50 | fileIds.push(id);
51 | }
52 | return fileIds;
53 | },
54 | });
55 |
56 | export const updatePosition = mutation({
57 | args: {
58 | id: v.id("files"),
59 | position: v.object({
60 | x: v.number(),
61 | y: v.number(),
62 | }),
63 | },
64 | handler: async (ctx, { id, position }) => {
65 | return await ctx.db.patch(id, { position });
66 | },
67 | });
68 |
69 | export const remove = mutation({
70 | args: { ids: v.array(v.id("files")) },
71 | handler: async (ctx, { ids }) => {
72 | for (const id of ids) {
73 | const file = await ctx.db.get(id);
74 | if (!file) throw new ConvexError(`File ${id} not found`);
75 |
76 | // Cancel timeout if file is uploading
77 | if (file.uploadState.kind === "uploading")
78 | await ctx.scheduler.cancel(file.uploadState.timeoutJobId);
79 |
80 | // Delete from storage if file is uploaded
81 | if (file.uploadState.kind === "uploaded")
82 | await ctx.storage.delete(file.uploadState.storageId);
83 |
84 | await ctx.db.delete(id);
85 | }
86 | },
87 | });
88 |
89 | export const startUpload = mutation({
90 | args: {
91 | id: v.id("files"),
92 | },
93 | handler: async (ctx, { id }): Promise> => {
94 | const file = await ctx.db.get(id);
95 | if (!file) throw new ConvexError("File not found");
96 |
97 | // Schedule initial timeout
98 | const timeoutJobId = await ctx.scheduler.runAfter(
99 | UPLOAD_TIMEOUT_MS,
100 | internal.files.handleUploadTimeout,
101 | {
102 | fileId: id,
103 | },
104 | );
105 |
106 | await ctx.db.patch(id, {
107 | uploadState: {
108 | kind: "uploading" as const,
109 | progress: 0,
110 | lastProgressAt: Date.now(),
111 | timeoutJobId,
112 | },
113 | });
114 |
115 | const updated = await ctx.db.get(id);
116 | if (!updated) throw new ConvexError("Failed to update file");
117 | return updated;
118 | },
119 | });
120 |
121 | export const updateUploadProgress = mutation({
122 | args: {
123 | id: v.id("files"),
124 | progress: v.number(),
125 | },
126 | handler: async (ctx, { id, progress }): Promise> => {
127 | const file = await ctx.db.get(id);
128 | if (!file) throw new ConvexError("File not found");
129 | if (file.uploadState.kind !== "uploading")
130 | throw new ConvexError("File is not in uploading state");
131 |
132 | // Cancel existing timeout
133 | await ctx.scheduler.cancel(file.uploadState.timeoutJobId);
134 |
135 | // Schedule new timeout
136 | const timeoutJobId = await ctx.scheduler.runAfter(
137 | UPLOAD_TIMEOUT_MS,
138 | internal.files.handleUploadTimeout,
139 | {
140 | fileId: id,
141 | },
142 | );
143 |
144 | await ctx.db.patch(id, {
145 | uploadState: {
146 | kind: "uploading" as const,
147 | progress,
148 | lastProgressAt: Date.now(),
149 | timeoutJobId,
150 | },
151 | });
152 |
153 | const updated = await ctx.db.get(id);
154 | if (!updated) throw new ConvexError("Failed to update file");
155 | return updated;
156 | },
157 | });
158 |
159 | export const completeUpload = mutation({
160 | args: {
161 | id: v.id("files"),
162 | storageId: v.id("_storage"),
163 | },
164 | handler: async (ctx, { id, storageId }) => {
165 | const file = await ctx.db.get(id);
166 | if (!file) throw new ConvexError("File not found");
167 | if (file.uploadState.kind !== "uploading")
168 | throw new ConvexError("File is not in uploading state");
169 |
170 | // Cancel timeout since upload is complete
171 | await ctx.scheduler.cancel(file.uploadState.timeoutJobId);
172 |
173 | const url = await ctx.storage.getUrl(storageId);
174 | if (!url) throw new ConvexError("Failed to get download URL");
175 |
176 | return await ctx.db.patch(id, {
177 | uploadState: {
178 | kind: "uploaded",
179 | storageId,
180 | url,
181 | },
182 | });
183 | },
184 | });
185 |
186 | export const getDownloadUrl = query({
187 | args: { ids: v.array(v.id("files")) },
188 | handler: async (ctx, { ids }) => {
189 | const urls = [];
190 | for (const id of ids) {
191 | const file = await ctx.db.get(id);
192 | if (!file) throw new ConvexError("File not found");
193 | if (file.uploadState.kind !== "uploaded") {
194 | urls.push(null);
195 | continue;
196 | }
197 | const url = await ctx.storage.getUrl(file.uploadState.storageId);
198 | urls.push(url);
199 | }
200 | return urls;
201 | },
202 | });
203 |
204 | export const updatePositions = mutation({
205 | args: {
206 | updates: v.array(
207 | v.object({
208 | id: v.id("files"),
209 | position: v.object({
210 | x: v.number(),
211 | y: v.number(),
212 | }),
213 | }),
214 | ),
215 | },
216 | handler: async (ctx, { updates }) => {
217 | await Promise.all(
218 | updates.map(({ id, position }) => ctx.db.patch(id, { position })),
219 | );
220 | },
221 | });
222 |
223 | export const setErrorState = mutation({
224 | args: {
225 | id: v.id("files"),
226 | message: v.string(),
227 | },
228 | handler: async (ctx, { id, message }) => {
229 | const file = await ctx.db.get(id);
230 | if (!file) throw new ConvexError("File not found");
231 |
232 | // Cancel timeout if file was uploading
233 | if (file.uploadState.kind === "uploading") {
234 | await ctx.scheduler.cancel(file.uploadState.timeoutJobId);
235 | }
236 |
237 | return await ctx.db.patch(id, {
238 | uploadState: {
239 | kind: "errored",
240 | message,
241 | },
242 | });
243 | },
244 | });
245 |
246 | // Internal mutation to handle upload timeouts
247 | export const handleUploadTimeout = internalMutation({
248 | args: {
249 | fileId: v.id("files"),
250 | },
251 | handler: async (ctx, { fileId }) => {
252 | const file = await ctx.db.get(fileId);
253 | if (!file) return; // File was deleted
254 | if (file.uploadState.kind !== "uploading") return; // File is no longer uploading
255 |
256 | // Mark the file as errored
257 | await ctx.db.patch(fileId, {
258 | uploadState: {
259 | kind: "errored",
260 | message: `Upload timed out - no progress for ${UPLOAD_TIMEOUT_MS / 1000} seconds`,
261 | },
262 | });
263 | },
264 | });
265 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
3 | import {
4 | CheckIcon,
5 | ChevronRightIcon,
6 | DotFilledIcon,
7 | } from "@radix-ui/react-icons";
8 |
9 | import { cn } from "@/lib/utils";
10 |
11 | const DropdownMenu = DropdownMenuPrimitive.Root;
12 |
13 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
14 |
15 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
16 |
17 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
18 |
19 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
20 |
21 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
22 |
23 | const DropdownMenuSubTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef & {
26 | inset?: boolean;
27 | }
28 | >(({ className, inset, children, ...props }, ref) => (
29 |
38 | {children}
39 |
40 |
41 | ));
42 | DropdownMenuSubTrigger.displayName =
43 | DropdownMenuPrimitive.SubTrigger.displayName;
44 |
45 | const DropdownMenuSubContent = React.forwardRef<
46 | React.ElementRef,
47 | React.ComponentPropsWithoutRef
48 | >(({ className, ...props }, ref) => (
49 |
57 | ));
58 | DropdownMenuSubContent.displayName =
59 | DropdownMenuPrimitive.SubContent.displayName;
60 |
61 | const DropdownMenuContent = React.forwardRef<
62 | React.ElementRef,
63 | React.ComponentPropsWithoutRef
64 | >(({ className, sideOffset = 4, ...props }, ref) => (
65 |
66 |
76 |
77 | ));
78 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
79 |
80 | const DropdownMenuItem = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef & {
83 | inset?: boolean;
84 | }
85 | >(({ className, inset, ...props }, ref) => (
86 |
95 | ));
96 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
97 |
98 | const DropdownMenuCheckboxItem = React.forwardRef<
99 | React.ElementRef,
100 | React.ComponentPropsWithoutRef
101 | >(({ className, children, checked, ...props }, ref) => (
102 |
111 |
112 |
113 |
114 |
115 |
116 | {children}
117 |
118 | ));
119 | DropdownMenuCheckboxItem.displayName =
120 | DropdownMenuPrimitive.CheckboxItem.displayName;
121 |
122 | const DropdownMenuRadioItem = React.forwardRef<
123 | React.ElementRef,
124 | React.ComponentPropsWithoutRef
125 | >(({ className, children, ...props }, ref) => (
126 |
134 |
135 |
136 |
137 |
138 |
139 | {children}
140 |
141 | ));
142 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
143 |
144 | const DropdownMenuLabel = React.forwardRef<
145 | React.ElementRef,
146 | React.ComponentPropsWithoutRef & {
147 | inset?: boolean;
148 | }
149 | >(({ className, inset, ...props }, ref) => (
150 |
159 | ));
160 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
161 |
162 | const DropdownMenuSeparator = React.forwardRef<
163 | React.ElementRef,
164 | React.ComponentPropsWithoutRef
165 | >(({ className, ...props }, ref) => (
166 |
171 | ));
172 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
173 |
174 | const DropdownMenuShortcut = ({
175 | className,
176 | ...props
177 | }: React.HTMLAttributes) => {
178 | return (
179 |
183 | );
184 | };
185 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
186 |
187 | export {
188 | DropdownMenu,
189 | DropdownMenuTrigger,
190 | DropdownMenuContent,
191 | DropdownMenuItem,
192 | DropdownMenuCheckboxItem,
193 | DropdownMenuRadioItem,
194 | DropdownMenuLabel,
195 | DropdownMenuSeparator,
196 | DropdownMenuShortcut,
197 | DropdownMenuGroup,
198 | DropdownMenuPortal,
199 | DropdownMenuSub,
200 | DropdownMenuSubContent,
201 | DropdownMenuSubTrigger,
202 | DropdownMenuRadioGroup,
203 | };
204 |
--------------------------------------------------------------------------------
/.cursor/rules/convex_rules.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples
3 | globs: **/*.{ts,tsx,js,jsx}
4 | alwaysApply: false
5 | ---
6 |
7 | # Convex guidelines
8 | ## Function guidelines
9 | ### New function syntax
10 | - ALWAYS use the new function syntax for Convex functions. For example:
11 | ```typescript
12 | import { query } from "./_generated/server";
13 | import { v } from "convex/values";
14 | export const f = query({
15 | args: {},
16 | returns: v.null(),
17 | handler: async (ctx, args) => {
18 | // Function body
19 | },
20 | });
21 | ```
22 |
23 | ### Http endpoint syntax
24 | - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
25 | ```typescript
26 | import { httpRouter } from "convex/server";
27 | import { httpAction } from "./_generated/server";
28 | const http = httpRouter();
29 | http.route({
30 | path: "/echo",
31 | method: "POST",
32 | handler: httpAction(async (ctx, req) => {
33 | const body = await req.bytes();
34 | return new Response(body, { status: 200 });
35 | }),
36 | });
37 | ```
38 | - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
39 |
40 | ### Validators
41 | - Below is an example of an array validator:
42 | ```typescript
43 | import { mutation } from "./_generated/server";
44 | import { v } from "convex/values";
45 |
46 | export default mutation({
47 | args: {
48 | simpleArray: v.array(v.union(v.string(), v.number())),
49 | },
50 | handler: async (ctx, args) => {
51 | //...
52 | },
53 | });
54 | ```
55 | - Below is an example of a schema with validators that codify a discriminated union type:
56 | ```typescript
57 | import { defineSchema, defineTable } from "convex/server";
58 | import { v } from "convex/values";
59 |
60 | export default defineSchema({
61 | results: defineTable(
62 | v.union(
63 | v.object({
64 | kind: v.literal("error"),
65 | errorMessage: v.string(),
66 | }),
67 | v.object({
68 | kind: v.literal("success"),
69 | value: v.number(),
70 | }),
71 | ),
72 | )
73 | });
74 | ```
75 | - Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:
76 | ```typescript
77 | import { query } from "./_generated/server";
78 | import { v } from "convex/values";
79 |
80 | export const exampleQuery = query({
81 | args: {},
82 | returns: v.null(),
83 | handler: async (ctx, args) => {
84 | console.log("This query returns a null value");
85 | return null;
86 | },
87 | });
88 | ```
89 | - Here are the valid Convex types along with their respective validators:
90 | Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
91 | | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
92 | | Id | string | `doc._id` | `v.id(tableName)` | |
93 | | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
94 | | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
95 | | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
96 | | Boolean | boolean | `true` | `v.boolean()` |
97 | | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
98 | | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
99 | | Array | Array] | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
100 | | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
101 | | Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". |
102 |
103 | ### Function registration
104 | - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
105 | - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
106 | - You CANNOT register a function through the `api` or `internal` objects.
107 | - ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator.
108 | - If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.
109 |
110 | ### Function calling
111 | - Use `ctx.runQuery` to call a query from a query, mutation, or action.
112 | - Use `ctx.runMutation` to call a mutation from a mutation or action.
113 | - Use `ctx.runAction` to call an action from an action.
114 | - ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
115 | - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
116 | - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
117 | - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
118 | ```
119 | export const f = query({
120 | args: { name: v.string() },
121 | returns: v.string(),
122 | handler: async (ctx, args) => {
123 | return "Hello " + args.name;
124 | },
125 | });
126 |
127 | export const g = query({
128 | args: {},
129 | returns: v.null(),
130 | handler: async (ctx, args) => {
131 | const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
132 | return null;
133 | },
134 | });
135 | ```
136 |
137 | ### Function references
138 | - Function references are pointers to registered Convex functions.
139 | - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
140 | - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
141 | - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
142 | - A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
143 | - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
144 |
145 | ### Api design
146 | - Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.
147 | - Use `query`, `mutation`, and `action` to define public functions.
148 | - Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.
149 |
150 | ### Pagination
151 | - Paginated queries are queries that return a list of results in incremental pages.
152 | - You can define pagination using the following syntax:
153 |
154 | ```ts
155 | import { v } from "convex/values";
156 | import { query, mutation } from "./_generated/server";
157 | import { paginationOptsValidator } from "convex/server";
158 | export const listWithExtraArg = query({
159 | args: { paginationOpts: paginationOptsValidator, author: v.string() },
160 | handler: async (ctx, args) => {
161 | return await ctx.db
162 | .query("messages")
163 | .filter((q) => q.eq(q.field("author"), args.author))
164 | .order("desc")
165 | .paginate(args.paginationOpts);
166 | },
167 | });
168 | ```
169 | Note: `paginationOpts` is an object with the following properties:
170 | - `numItems`: the maximum number of documents to return (the validator is `v.number()`)
171 | - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
172 | - A query that ends in `.paginate()` returns an object that has the following properties:
173 | - page (contains an array of documents that you fetches)
174 | - isDone (a boolean that represents whether or not this is the last page of documents)
175 | - continueCursor (a string that represents the cursor to use to fetch the next page of documents)
176 |
177 |
178 | ## Validator guidelines
179 | - `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead.
180 | - Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported.
181 |
182 | ## Schema guidelines
183 | - Always define your schema in `convex/schema.ts`.
184 | - Always import the schema definition functions from `convex/server`:
185 | - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
186 | - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
187 | - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
188 |
189 | ## Typescript guidelines
190 | - You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
191 | - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query:
192 | ```ts
193 | import { query } from "./_generated/server";
194 | import { Doc, Id } from "./_generated/dataModel";
195 |
196 | export const exampleQuery = query({
197 | args: { userIds: v.array(v.id("users")) },
198 | returns: v.record(v.id("users"), v.string()),
199 | handler: async (ctx, args) => {
200 | const idToUsername: Record, string> = {};
201 | for (const userId of args.userIds) {
202 | const user = await ctx.db.get(userId);
203 | if (user) {
204 | users[user._id] = user.username;
205 | }
206 | }
207 |
208 | return idToUsername;
209 | },
210 | });
211 | ```
212 | - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
213 | - Always use `as const` for string literals in discriminated union types.
214 | - When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];`
215 | - When using the `Record` type, make sure to always define your records as `const record: Record = {...};`
216 | - Always add `@types/node` to your `package.json` when using any Node.js built-in modules.
217 |
218 | ## Full text search guidelines
219 | - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
220 |
221 | const messages = await ctx.db
222 | .query("messages")
223 | .withSearchIndex("search_body", (q) =>
224 | q.search("body", "hello hi").eq("channel", "#general"),
225 | )
226 | .take(10);
227 |
228 | ## Query guidelines
229 | - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
230 | - Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result.
231 | - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
232 | - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
233 | ### Ordering
234 | - By default Convex always returns documents in ascending `_creationTime` order.
235 | - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
236 | - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
237 |
238 |
239 | ## Mutation guidelines
240 | - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist.
241 | - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist.
242 |
243 | ## Action guidelines
244 | - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
245 | - Never use `ctx.db` inside of an action. Actions don't have access to the database.
246 | - Below is an example of the syntax for an action:
247 | ```ts
248 | import { action } from "./_generated/server";
249 |
250 | export const exampleAction = action({
251 | args: {},
252 | returns: v.null(),
253 | handler: async (ctx, args) => {
254 | console.log("This action does not return anything");
255 | return null;
256 | },
257 | });
258 | ```
259 |
260 | ## Scheduling guidelines
261 | ### Cron guidelines
262 | - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
263 | - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
264 | - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
265 | ```ts
266 | import { cronJobs } from "convex/server";
267 | import { internal } from "./_generated/api";
268 | import { internalAction } from "./_generated/server";
269 |
270 | const empty = internalAction({
271 | args: {},
272 | returns: v.null(),
273 | handler: async (ctx, args) => {
274 | console.log("empty");
275 | },
276 | });
277 |
278 | const crons = cronJobs();
279 |
280 | // Run `internal.crons.empty` every two hours.
281 | crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
282 |
283 | export default crons;
284 | ```
285 | - You can register Convex functions within `crons.ts` just like any other file.
286 | - If a cron calls an internal function, always import the `internal` object from '_generated/api`, even if the internal function is registered in the same file.
287 |
288 |
289 | ## File storage guidelines
290 | - Convex includes file storage for large files like images, videos, and PDFs.
291 | - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
292 | - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
293 |
294 | Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
295 | ```
296 | import { query } from "./_generated/server";
297 | import { Id } from "./_generated/dataModel";
298 |
299 | type FileMetadata = {
300 | _id: Id<"_storage">;
301 | _creationTime: number;
302 | contentType?: string;
303 | sha256: string;
304 | size: number;
305 | }
306 |
307 | export const exampleQuery = query({
308 | args: { fileId: v.id("_storage") },
309 | returns: v.null();
310 | handler: async (ctx, args) => {
311 | const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId);
312 | console.log(metadata);
313 | return null;
314 | },
315 | });
316 | ```
317 | - Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
318 |
319 |
320 | # Examples:
321 | ## Example: chat-app
322 |
323 | ### Task
324 | ```
325 | Create a real-time chat application backend with AI responses. The app should:
326 | - Allow creating users with names
327 | - Support multiple chat channels
328 | - Enable users to send messages to channels
329 | - Automatically generate AI responses to user messages
330 | - Show recent message history
331 |
332 | The backend should provide APIs for:
333 | 1. User management (creation)
334 | 2. Channel management (creation)
335 | 3. Message operations (sending, listing)
336 | 4. AI response generation using OpenAI's GPT-4
337 |
338 | Messages should be stored with their channel, author, and content. The system should maintain message order
339 | and limit history display to the 10 most recent messages per channel.
340 |
341 | ```
342 |
343 | ### Analysis
344 | 1. Task Requirements Summary:
345 | - Build a real-time chat backend with AI integration
346 | - Support user creation
347 | - Enable channel-based conversations
348 | - Store and retrieve messages with proper ordering
349 | - Generate AI responses automatically
350 |
351 | 2. Main Components Needed:
352 | - Database tables: users, channels, messages
353 | - Public APIs for user/channel management
354 | - Message handling functions
355 | - Internal AI response generation system
356 | - Context loading for AI responses
357 |
358 | 3. Public API and Internal Functions Design:
359 | Public Mutations:
360 | - createUser:
361 | - file path: convex/index.ts
362 | - arguments: {name: v.string()}
363 | - returns: v.object({userId: v.id("users")})
364 | - purpose: Create a new user with a given name
365 | - createChannel:
366 | - file path: convex/index.ts
367 | - arguments: {name: v.string()}
368 | - returns: v.object({channelId: v.id("channels")})
369 | - purpose: Create a new channel with a given name
370 | - sendMessage:
371 | - file path: convex/index.ts
372 | - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()}
373 | - returns: v.null()
374 | - purpose: Send a message to a channel and schedule a response from the AI
375 |
376 | Public Queries:
377 | - listMessages:
378 | - file path: convex/index.ts
379 | - arguments: {channelId: v.id("channels")}
380 | - returns: v.array(v.object({
381 | _id: v.id("messages"),
382 | _creationTime: v.number(),
383 | channelId: v.id("channels"),
384 | authorId: v.optional(v.id("users")),
385 | content: v.string(),
386 | }))
387 | - purpose: List the 10 most recent messages from a channel in descending creation order
388 |
389 | Internal Functions:
390 | - generateResponse:
391 | - file path: convex/index.ts
392 | - arguments: {channelId: v.id("channels")}
393 | - returns: v.null()
394 | - purpose: Generate a response from the AI for a given channel
395 | - loadContext:
396 | - file path: convex/index.ts
397 | - arguments: {channelId: v.id("channels")}
398 | - returns: v.array(v.object({
399 | _id: v.id("messages"),
400 | _creationTime: v.number(),
401 | channelId: v.id("channels"),
402 | authorId: v.optional(v.id("users")),
403 | content: v.string(),
404 | }))
405 | - writeAgentResponse:
406 | - file path: convex/index.ts
407 | - arguments: {channelId: v.id("channels"), content: v.string()}
408 | - returns: v.null()
409 | - purpose: Write an AI response to a given channel
410 |
411 | 4. Schema Design:
412 | - users
413 | - validator: { name: v.string() }
414 | - indexes:
415 | - channels
416 | - validator: { name: v.string() }
417 | - indexes:
418 | - messages
419 | - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() }
420 | - indexes
421 | - by_channel: ["channelId"]
422 |
423 | 5. Background Processing:
424 | - AI response generation runs asynchronously after each user message
425 | - Uses OpenAI's GPT-4 to generate contextual responses
426 | - Maintains conversation context using recent message history
427 |
428 |
429 | ### Implementation
430 |
431 | #### package.json
432 | ```typescript
433 | {
434 | "name": "chat-app",
435 | "description": "This example shows how to build a chat app without authentication.",
436 | "version": "1.0.0",
437 | "dependencies": {
438 | "convex": "^1.17.4",
439 | "openai": "^4.79.0"
440 | },
441 | "devDependencies": {
442 | "typescript": "^5.7.3"
443 | }
444 | }
445 | ```
446 |
447 | #### tsconfig.json
448 | ```typescript
449 | {
450 | "compilerOptions": {
451 | "target": "ESNext",
452 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
453 | "skipLibCheck": true,
454 | "allowSyntheticDefaultImports": true,
455 | "strict": true,
456 | "forceConsistentCasingInFileNames": true,
457 | "module": "ESNext",
458 | "moduleResolution": "Bundler",
459 | "resolveJsonModule": true,
460 | "isolatedModules": true,
461 | "allowImportingTsExtensions": true,
462 | "noEmit": true,
463 | "jsx": "react-jsx"
464 | },
465 | "exclude": ["convex"],
466 | "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
467 | }
468 | ```
469 |
470 | #### convex/index.ts
471 | ```typescript
472 | import {
473 | query,
474 | mutation,
475 | internalQuery,
476 | internalMutation,
477 | internalAction,
478 | } from "./_generated/server";
479 | import { v } from "convex/values";
480 | import OpenAI from "openai";
481 | import { internal } from "./_generated/api";
482 |
483 | /**
484 | * Create a user with a given name.
485 | */
486 | export const createUser = mutation({
487 | args: {
488 | name: v.string(),
489 | },
490 | returns: v.id("users"),
491 | handler: async (ctx, args) => {
492 | return await ctx.db.insert("users", { name: args.name });
493 | },
494 | });
495 |
496 | /**
497 | * Create a channel with a given name.
498 | */
499 | export const createChannel = mutation({
500 | args: {
501 | name: v.string(),
502 | },
503 | returns: v.id("channels"),
504 | handler: async (ctx, args) => {
505 | return await ctx.db.insert("channels", { name: args.name });
506 | },
507 | });
508 |
509 | /**
510 | * List the 10 most recent messages from a channel in descending creation order.
511 | */
512 | export const listMessages = query({
513 | args: {
514 | channelId: v.id("channels"),
515 | },
516 | returns: v.array(
517 | v.object({
518 | _id: v.id("messages"),
519 | _creationTime: v.number(),
520 | channelId: v.id("channels"),
521 | authorId: v.optional(v.id("users")),
522 | content: v.string(),
523 | }),
524 | ),
525 | handler: async (ctx, args) => {
526 | const messages = await ctx.db
527 | .query("messages")
528 | .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
529 | .order("desc")
530 | .take(10);
531 | return messages;
532 | },
533 | });
534 |
535 | /**
536 | * Send a message to a channel and schedule a response from the AI.
537 | */
538 | export const sendMessage = mutation({
539 | args: {
540 | channelId: v.id("channels"),
541 | authorId: v.id("users"),
542 | content: v.string(),
543 | },
544 | returns: v.null(),
545 | handler: async (ctx, args) => {
546 | const channel = await ctx.db.get(args.channelId);
547 | if (!channel) {
548 | throw new Error("Channel not found");
549 | }
550 | const user = await ctx.db.get(args.authorId);
551 | if (!user) {
552 | throw new Error("User not found");
553 | }
554 | await ctx.db.insert("messages", {
555 | channelId: args.channelId,
556 | authorId: args.authorId,
557 | content: args.content,
558 | });
559 | await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
560 | channelId: args.channelId,
561 | });
562 | return null;
563 | },
564 | });
565 |
566 | const openai = new OpenAI();
567 |
568 | export const generateResponse = internalAction({
569 | args: {
570 | channelId: v.id("channels"),
571 | },
572 | returns: v.null(),
573 | handler: async (ctx, args) => {
574 | const context = await ctx.runQuery(internal.index.loadContext, {
575 | channelId: args.channelId,
576 | });
577 | const response = await openai.chat.completions.create({
578 | model: "gpt-4o",
579 | messages: context,
580 | });
581 | const content = response.choices[0].message.content;
582 | if (!content) {
583 | throw new Error("No content in response");
584 | }
585 | await ctx.runMutation(internal.index.writeAgentResponse, {
586 | channelId: args.channelId,
587 | content,
588 | });
589 | return null;
590 | },
591 | });
592 |
593 | export const loadContext = internalQuery({
594 | args: {
595 | channelId: v.id("channels"),
596 | },
597 | returns: v.array(
598 | v.object({
599 | role: v.union(v.literal("user"), v.literal("assistant")),
600 | content: v.string(),
601 | }),
602 | ),
603 | handler: async (ctx, args) => {
604 | const channel = await ctx.db.get(args.channelId);
605 | if (!channel) {
606 | throw new Error("Channel not found");
607 | }
608 | const messages = await ctx.db
609 | .query("messages")
610 | .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
611 | .order("desc")
612 | .take(10);
613 |
614 | const result = [];
615 | for (const message of messages) {
616 | if (message.authorId) {
617 | const user = await ctx.db.get(message.authorId);
618 | if (!user) {
619 | throw new Error("User not found");
620 | }
621 | result.push({
622 | role: "user" as const,
623 | content: `${user.name}: ${message.content}`,
624 | });
625 | } else {
626 | result.push({ role: "assistant" as const, content: message.content });
627 | }
628 | }
629 | return result;
630 | },
631 | });
632 |
633 | export const writeAgentResponse = internalMutation({
634 | args: {
635 | channelId: v.id("channels"),
636 | content: v.string(),
637 | },
638 | returns: v.null(),
639 | handler: async (ctx, args) => {
640 | await ctx.db.insert("messages", {
641 | channelId: args.channelId,
642 | content: args.content,
643 | });
644 | return null;
645 | },
646 | });
647 | ```
648 |
649 | #### convex/schema.ts
650 | ```typescript
651 | import { defineSchema, defineTable } from "convex/server";
652 | import { v } from "convex/values";
653 |
654 | export default defineSchema({
655 | channels: defineTable({
656 | name: v.string(),
657 | }),
658 |
659 | users: defineTable({
660 | name: v.string(),
661 | }),
662 |
663 | messages: defineTable({
664 | channelId: v.id("channels"),
665 | authorId: v.optional(v.id("users")),
666 | content: v.string(),
667 | }).index("by_channel", ["channelId"]),
668 | });
669 | ```
670 |
671 | #### src/App.tsx
672 | ```typescript
673 | export default function App() {
674 | return Hello World
;
675 | }
676 | ```
677 |
678 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 |
--------------------------------------------------------------------------------