├── .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 |
7 | 8 | 9 |
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 | Convex Logo 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 | 15 | 21 | 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 | 23 | 24 | 25 | Download {fileCount} Files 26 | 27 | To download multiple files, you'll need to select a destination 28 | folder. Click continue to choose where to save the files. 29 | 30 | 31 | 32 | 39 | 42 | 43 | 44 | 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 | 26 | 27 | 28 | 29 | Delete {fileCount > 1 ? `${fileCount} Files` : "File"} 30 | 31 | 32 | {fileCount > 1 33 | ? `Are you sure you want to delete these ${fileCount} files? This action cannot be undone.` 34 | : "Are you sure you want to delete this file? This action cannot be undone."} 35 | 36 | 37 | 38 | 45 | 48 | 49 | 50 | 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 | 36 | 37 | 38 | Settings 39 | 40 |
41 |
42 | 43 | setConvexUrl(e.target.value)} 47 | placeholder={defaultConvexUrl} 48 | /> 49 |
50 |
51 | 52 | setDashboardUrl(e.target.value)} 56 | placeholder={defaultDashboardUrl} 57 | /> 58 |
59 |
60 |
61 | 64 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | export default SettingsDialog; 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ss1](media/ss1.png) 2 | ![ss2](media/ss2.png) 3 | ![ss3](media/ss3.png) 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 |
31 | 38 | 44 | 45 |
46 | ); 47 | 48 | if (state.kind === "uploading") 49 | return ( 50 |
56 |
68 | 75 | 82 | 83 |
84 | ); 85 | 86 | if (state.kind === "errored") 87 | return ( 88 |
94 | 101 | 107 | 108 |
109 | ); 110 | 111 | if (state.kind === "uploaded") 112 | return ( 113 |
119 | 126 | 133 | 134 |
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 |
110 |
119 |
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 | preview 26 |
27 | ); 28 | 29 | // Video files 30 | if (type.startsWith("video/") || /\.(mp4|mov|avi|wmv|flv|webm)$/i.test(type)) 31 | return ( 32 |
33 |
35 | ); 36 | 37 | // Audio files 38 | if (type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a)$/i.test(type)) 39 | return ( 40 |
41 |
43 | ); 44 | 45 | // PDF files - show iframe preview 46 | if (type === "application/pdf" || type.endsWith(".pdf")) 47 | return ( 48 |
49 |