├── www
├── .gitignore
├── ui
│ └── components
│ │ ├── button
│ │ ├── index.ts
│ │ └── button.tsx
│ │ ├── dialog
│ │ ├── index.ts
│ │ └── dialog.tsx
│ │ └── index.ts
├── app
│ ├── components
│ │ ├── image-capture-dialog-desktop
│ │ │ ├── index.ts
│ │ │ └── image-capture-dialog-desktop.tsx
│ │ ├── image-capture-dialog-mobile
│ │ │ ├── index.ts
│ │ │ └── image-capture-dialog-mobile.tsx
│ │ └── index.ts
│ ├── page.tsx
│ ├── layout.tsx
│ ├── content.tsx
│ └── globals.css
├── public
│ ├── og
│ │ └── preview.png
│ ├── icon-192x192.png
│ ├── icon-512x512.png
│ ├── manifest.json
│ ├── github.svg
│ ├── sw.js
│ └── workbox-8ad7dfbc.js
├── postcss.config.mjs
├── lib
│ └── utils.ts
├── next-env.d.ts
├── components.json
├── next.config.ts
├── tsconfig.json
├── package.json
└── README.md
├── .npmrc
├── examples
├── with-vite
│ ├── src
│ │ ├── vite-env.d.ts
│ │ ├── ui
│ │ │ └── components
│ │ │ │ ├── button
│ │ │ │ ├── index.ts
│ │ │ │ └── button.tsx
│ │ │ │ ├── dialog
│ │ │ │ ├── index.ts
│ │ │ │ └── dialog.tsx
│ │ │ │ └── index.ts
│ │ ├── lib
│ │ │ └── utils.ts
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── index.css
│ │ └── views
│ │ │ ├── ImageCaptureDialogDesktop.tsx
│ │ │ └── ImageCaptureDialogMobile.tsx
│ ├── tsconfig.json
│ ├── .gitignore
│ ├── vite.config.ts
│ ├── index.html
│ ├── components.json
│ ├── tsconfig.node.json
│ ├── eslint.config.js
│ ├── tsconfig.app.json
│ ├── package.json
│ └── README.md
└── with-nextjs
│ ├── ui
│ └── components
│ │ ├── button
│ │ ├── index.ts
│ │ └── button.tsx
│ │ ├── dialog
│ │ ├── index.ts
│ │ └── dialog.tsx
│ │ └── index.ts
│ ├── app
│ ├── components
│ │ ├── image-capture-dialog-mobile
│ │ │ ├── index.ts
│ │ │ └── image-capture-dialog-mobile.tsx
│ │ ├── image-capture-dialog-desktop
│ │ │ ├── index.ts
│ │ │ └── image-capture-dialog-desktop.tsx
│ │ └── index.ts
│ ├── page.tsx
│ ├── layout.tsx
│ ├── content.tsx
│ └── globals.css
│ ├── postcss.config.mjs
│ ├── next.config.ts
│ ├── lib
│ └── utils.ts
│ ├── components.json
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── package.json
│ └── README.md
├── .npmignore
├── pnpm-workspace.yaml
├── src
├── index.ts
└── WebCamera.tsx
├── tsconfig.json
├── .gitignore
├── vite.config.ts
├── LICENSE
├── package.json
└── README.md
/www/.gitignore:
--------------------------------------------------------------------------------
1 | out/
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN}
--------------------------------------------------------------------------------
/www/ui/components/button/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./button";
2 |
--------------------------------------------------------------------------------
/www/ui/components/dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./dialog";
2 |
--------------------------------------------------------------------------------
/examples/with-vite/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | node_modules
3 | *.log
4 | *.lock
5 | tsconfig.tsbuildinfo
6 |
--------------------------------------------------------------------------------
/examples/with-nextjs/ui/components/button/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./button";
2 |
--------------------------------------------------------------------------------
/examples/with-nextjs/ui/components/dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./dialog";
2 |
--------------------------------------------------------------------------------
/examples/with-vite/src/ui/components/button/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./button";
2 |
--------------------------------------------------------------------------------
/examples/with-vite/src/ui/components/dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./dialog";
2 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "."
3 | - "examples/*"
4 | - "www"
5 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./WebCamera";
2 |
3 | export { WebCamera as default } from "./WebCamera";
4 |
--------------------------------------------------------------------------------
/www/app/components/image-capture-dialog-desktop/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./image-capture-dialog-desktop";
2 |
--------------------------------------------------------------------------------
/www/app/components/image-capture-dialog-mobile/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./image-capture-dialog-mobile";
2 |
--------------------------------------------------------------------------------
/www/public/og/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shivantra/react-web-camera/HEAD/www/public/og/preview.png
--------------------------------------------------------------------------------
/www/public/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shivantra/react-web-camera/HEAD/www/public/icon-192x192.png
--------------------------------------------------------------------------------
/www/public/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shivantra/react-web-camera/HEAD/www/public/icon-512x512.png
--------------------------------------------------------------------------------
/examples/with-nextjs/app/components/image-capture-dialog-mobile/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./image-capture-dialog-mobile";
2 |
--------------------------------------------------------------------------------
/www/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/components/image-capture-dialog-desktop/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./image-capture-dialog-desktop";
2 |
--------------------------------------------------------------------------------
/www/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Content from "./content";
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/examples/with-nextjs/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Content from "./content";
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/www/app/components/index.ts:
--------------------------------------------------------------------------------
1 | export { ImageCaptureDialogMobile } from "./image-capture-dialog-mobile";
2 | export { ImageCaptureDialogDesktop } from "./image-capture-dialog-desktop";
3 |
--------------------------------------------------------------------------------
/examples/with-nextjs/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/components/index.ts:
--------------------------------------------------------------------------------
1 | export { ImageCaptureDialogMobile } from "./image-capture-dialog-mobile";
2 | export { ImageCaptureDialogDesktop } from "./image-capture-dialog-desktop";
3 |
--------------------------------------------------------------------------------
/www/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/examples/with-nextjs/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/examples/with-vite/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/www/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
7 |
--------------------------------------------------------------------------------
/examples/with-vite/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/examples/with-vite/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ],
7 | "compilerOptions": {
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/www/ui/components/index.ts:
--------------------------------------------------------------------------------
1 | export { Button, buttonVariants } from "./button";
2 | export {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogOverlay,
10 | DialogPortal,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "./dialog";
14 |
--------------------------------------------------------------------------------
/examples/with-nextjs/ui/components/index.ts:
--------------------------------------------------------------------------------
1 | export { Button, buttonVariants } from "./button";
2 | export {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogOverlay,
10 | DialogPortal,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "./dialog";
14 |
--------------------------------------------------------------------------------
/examples/with-vite/src/ui/components/index.ts:
--------------------------------------------------------------------------------
1 | export { Button, buttonVariants } from "./button";
2 | export {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogOverlay,
10 | DialogPortal,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "./dialog";
14 |
--------------------------------------------------------------------------------
/examples/with-vite/.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 |
--------------------------------------------------------------------------------
/examples/with-vite/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react-swc";
3 | import path from "path";
4 | import tailwindcss from "@tailwindcss/vite";
5 |
6 | // https://vite.dev/config/
7 | export default defineConfig({
8 | plugins: [react(), tailwindcss()],
9 | resolve: {
10 | alias: {
11 | "@": path.resolve(__dirname, "./src"),
12 | },
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/examples/with-vite/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "ESNext",
5 | "moduleResolution": "Bundler",
6 | "jsx": "react-jsx",
7 | "declaration": true,
8 | "declarationDir": "dist",
9 | "emitDeclarationOnly": true,
10 | "strict": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "forceConsistentCasingInFileNames": true
14 | },
15 | "include": ["src"]
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Node
2 | node_modules/
3 | npm-debug.log*
4 | yarn-debug.log*
5 | pnpm-debug.log*
6 | package-lock.json
7 | yarn.lock
8 | dist/
9 |
10 | # Vite
11 | .vite/
12 | .vitepress/
13 |
14 | # Next
15 | .next/
16 |
17 | # TypeScript
18 | *.tsbuildinfo
19 |
20 | # Testing
21 | coverage/
22 | *.log
23 | *.lcov
24 |
25 | # Editor configs
26 | .vscode/
27 | .idea/
28 | *.swp
29 |
30 | # MacOS & Linux
31 | .DS_Store
32 | Thumbs.db
33 |
34 | # Misc
35 | .env
36 | .env.*
37 | playground/
--------------------------------------------------------------------------------
/www/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/ui/components",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/examples/with-vite/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": "",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/ui/components",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/examples/with-nextjs/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/ui/components",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/examples/with-vite/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2023",
4 | "lib": ["ES2023"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "verbatimModuleSyntax": true,
12 | "moduleDetection": "force",
13 | "noEmit": true,
14 |
15 | /* Linting */
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noFallthroughCasesInSwitch": true
20 | },
21 | "include": ["vite.config.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/www/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 | import withPWAInit from "@ducanh2912/next-pwa";
3 |
4 | const isProd = process.env.NODE_ENV === "production";
5 |
6 | const withPWA = withPWAInit({
7 | dest: "public",
8 | register: true,
9 | disable: !isProd,
10 | scope: "/react-web-camera/",
11 | });
12 |
13 | const config: NextConfig = {
14 | output: "export",
15 |
16 | basePath: "/react-web-camera",
17 |
18 | assetPrefix: isProd ? "/react-web-camera/" : undefined,
19 |
20 | trailingSlash: true,
21 |
22 | images: { unoptimized: true },
23 | };
24 |
25 | export default withPWA(config);
26 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import dts from "vite-plugin-dts";
3 |
4 | export default defineConfig({
5 | build: {
6 | lib: {
7 | entry: "src/index.ts",
8 | name: "React Web Camera",
9 | fileName: (format) =>
10 | format === "cjs" ? "react-web-camera.cjs" : "react-web-camera.js",
11 | formats: ["es", "cjs"],
12 | },
13 | rollupOptions: {
14 | external: ["react", "react-dom"],
15 | output: {
16 | globals: {
17 | react: "React",
18 | "react-dom": "ReactDOM",
19 | },
20 | },
21 | },
22 | },
23 | plugins: [dts({ insertTypesEntry: true })],
24 | });
25 |
--------------------------------------------------------------------------------
/examples/with-nextjs/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/www/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/examples/with-vite/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 | import { globalIgnores } from 'eslint/config'
7 |
8 | export default tseslint.config([
9 | globalIgnores(['dist']),
10 | {
11 | files: ['**/*.{ts,tsx}'],
12 | extends: [
13 | js.configs.recommended,
14 | tseslint.configs.recommended,
15 | reactHooks.configs['recommended-latest'],
16 | reactRefresh.configs.vite,
17 | ],
18 | languageOptions: {
19 | ecmaVersion: 2020,
20 | globals: globals.browser,
21 | },
22 | },
23 | ])
24 |
--------------------------------------------------------------------------------
/examples/with-nextjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/examples/with-vite/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "baseUrl": ".",
23 | "paths": {
24 | "@/*": ["./src/*"]
25 | }
26 | },
27 | "include": ["src"]
28 | }
29 |
--------------------------------------------------------------------------------
/www/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "React Web Camera",
3 | "short_name": "React Web Camera",
4 | "description": "A lightweight React component to capture images from the browser camera. Switch front/back, control quality, and use it in PWAs.",
5 | "start_url": "/react-web-camera/?source=pwa",
6 | "scope": "/react-web-camera/",
7 | "display": "standalone",
8 | "background_color": "#ffffff",
9 | "theme_color": "#000000",
10 | "icons": [
11 | {
12 | "src": "https://shivantra.com/react-web-camera/icon-192x192.png",
13 | "sizes": "192x192",
14 | "type": "image/png"
15 | },
16 | {
17 | "src": "https://shivantra.com/react-web-camera/icon-512x512.png",
18 | "sizes": "512x512",
19 | "type": "image/png"
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Geist, Geist_Mono } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const geistSans = Geist({
6 | variable: "--font-geist-sans",
7 | subsets: ["latin"],
8 | });
9 |
10 | const geistMono = Geist_Mono({
11 | variable: "--font-geist-mono",
12 | subsets: ["latin"],
13 | });
14 |
15 | export const metadata: Metadata = {
16 | title: "Create Next App",
17 | description: "Generated by create next app",
18 | };
19 |
20 | export default function RootLayout({
21 | children,
22 | }: Readonly<{
23 | children: React.ReactNode;
24 | }>) {
25 | return (
26 |
27 |
30 | {children}
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/examples/with-nextjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start"
9 | },
10 | "dependencies": {
11 | "@radix-ui/react-dialog": "^1.1.15",
12 | "@radix-ui/react-slot": "^1.2.3",
13 | "class-variance-authority": "^0.7.1",
14 | "clsx": "^2.1.1",
15 | "lucide-react": "^0.541.0",
16 | "next": "15.5.0",
17 | "react": "19.1.0",
18 | "react-dom": "19.1.0",
19 | "@shivantra/react-web-camera": "workspace:*",
20 | "tailwind-merge": "^3.3.1"
21 | },
22 | "devDependencies": {
23 | "@tailwindcss/postcss": "^4",
24 | "@types/node": "^20",
25 | "@types/react": "^19",
26 | "@types/react-dom": "^19",
27 | "tailwindcss": "^4",
28 | "tw-animate-css": "^1.3.7",
29 | "typescript": "^5"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/www/public/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/www/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "deploy": "npm run build && gh-pages --nojekyll -d out -b gh-pages"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-dialog": "^1.1.15",
13 | "@radix-ui/react-slot": "^1.2.3",
14 | "@shivantra/react-web-camera": "workspace:*",
15 | "class-variance-authority": "^0.7.1",
16 | "clsx": "^2.1.1",
17 | "lucide-react": "^0.541.0",
18 | "next": "15.5.0",
19 | "react": "19.1.0",
20 | "react-dom": "19.1.0",
21 | "tailwind-merge": "^3.3.1"
22 | },
23 | "devDependencies": {
24 | "@ducanh2912/next-pwa": "^10.2.9",
25 | "@tailwindcss/postcss": "^4",
26 | "@types/next-pwa": "^5.6.9",
27 | "@types/node": "^20",
28 | "@types/react": "^19",
29 | "@types/react-dom": "^19",
30 | "gh-pages": "^6.3.0",
31 | "tailwindcss": "^4",
32 | "tw-animate-css": "^1.3.7",
33 | "typescript": "^5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Shivantra Solutions Private Limited
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.
--------------------------------------------------------------------------------
/examples/with-vite/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-vite",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@radix-ui/react-dialog": "^1.1.15",
14 | "@radix-ui/react-slot": "^1.2.3",
15 | "@tailwindcss/vite": "^4.1.12",
16 | "class-variance-authority": "^0.7.1",
17 | "clsx": "^2.1.1",
18 | "lucide-react": "^0.541.0",
19 | "react": "^19.1.1",
20 | "react-dom": "^19.1.1",
21 | "@shivantra/react-web-camera": "workspace:*",
22 | "tailwind-merge": "^3.3.1",
23 | "tailwindcss": "^4.1.12"
24 | },
25 | "devDependencies": {
26 | "@eslint/js": "^9.33.0",
27 | "@types/node": "^24.1.0",
28 | "@types/react": "^19.1.10",
29 | "@types/react-dom": "^19.1.7",
30 | "@vitejs/plugin-react-swc": "^4.0.0",
31 | "eslint": "^9.33.0",
32 | "eslint-plugin-react-hooks": "^5.2.0",
33 | "eslint-plugin-react-refresh": "^0.4.20",
34 | "globals": "^16.3.0",
35 | "tw-animate-css": "^1.3.7",
36 | "typescript": "~5.8.3",
37 | "typescript-eslint": "^8.39.1",
38 | "vite": "^7.1.2"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/www/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/examples/with-nextjs/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@shivantra/react-web-camera",
3 | "version": "1.0.0",
4 | "description": "Web camera component for React (by Shivantra)",
5 | "license": "MIT",
6 | "sideEffects": false,
7 | "type": "module",
8 | "main": "./dist/react-web-camera.cjs",
9 | "module": "./dist/react-web-camera.js",
10 | "types": "./dist/index.d.ts",
11 | "exports": {
12 | ".": {
13 | "types": "./dist/index.d.ts",
14 | "import": "./dist/react-web-camera.js",
15 | "require": "./dist/react-web-camera.cjs"
16 | },
17 | "./package.json": "./package.json"
18 | },
19 | "files": [
20 | "dist",
21 | "README.md",
22 | "LICENSE"
23 | ],
24 | "keywords": [
25 | "react",
26 | "camera",
27 | "webcam",
28 | "media-devices",
29 | "components",
30 | "shivantra"
31 | ],
32 | "scripts": {
33 | "dev": "vite",
34 | "build": "vite build",
35 | "typecheck": "tsc --noEmit",
36 | "clean": "rimraf dist",
37 | "prepublishOnly": "npm run clean && npm run build"
38 | },
39 | "peerDependencies": {
40 | "react": ">=17 || >=18",
41 | "react-dom": ">=17 || >=18"
42 | },
43 | "devDependencies": {
44 | "@types/react": "^18.2.0",
45 | "@types/react-dom": "^18.2.0",
46 | "rimraf": "^6.0.0",
47 | "typescript": "^5.4.0",
48 | "vite": "^5.0.0",
49 | "vite-plugin-dts": "^3.7.0"
50 | },
51 | "engines": {
52 | "node": ">=18.0.0"
53 | },
54 | "publishConfig": {
55 | "access": "public"
56 | },
57 | "repository": {
58 | "type": "git",
59 | "url": "git+https://github.com/shivantra/react-web-camera.git"
60 | },
61 | "bugs": {
62 | "url": "https://github.com/shivantra/react-web-camera/issues"
63 | },
64 | "author": {
65 | "name": "Shivantra",
66 | "email": "contact@shivantra.com",
67 | "url": "https://shivantra.com"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/www/ui/components/button/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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/examples/with-nextjs/ui/components/button/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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/examples/with-vite/src/ui/components/button/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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | );
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean;
47 | }) {
48 | const Comp = asChild ? Slot : "button";
49 |
50 | return (
51 |
56 | );
57 | }
58 |
59 | export { Button, buttonVariants };
60 |
--------------------------------------------------------------------------------
/examples/with-vite/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config([
16 | globalIgnores(['dist']),
17 | {
18 | files: ['**/*.{ts,tsx}'],
19 | extends: [
20 | // Other configs...
21 |
22 | // Remove tseslint.configs.recommended and replace with this
23 | ...tseslint.configs.recommendedTypeChecked,
24 | // Alternatively, use this for stricter rules
25 | ...tseslint.configs.strictTypeChecked,
26 | // Optionally, add this for stylistic rules
27 | ...tseslint.configs.stylisticTypeChecked,
28 |
29 | // Other configs...
30 | ],
31 | languageOptions: {
32 | parserOptions: {
33 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
34 | tsconfigRootDir: import.meta.dirname,
35 | },
36 | // other options...
37 | },
38 | },
39 | ])
40 | ```
41 |
42 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
43 |
44 | ```js
45 | // eslint.config.js
46 | import reactX from 'eslint-plugin-react-x'
47 | import reactDom from 'eslint-plugin-react-dom'
48 |
49 | export default tseslint.config([
50 | globalIgnores(['dist']),
51 | {
52 | files: ['**/*.{ts,tsx}'],
53 | extends: [
54 | // Other configs...
55 | // Enable lint rules for React
56 | reactX.configs['recommended-typescript'],
57 | // Enable lint rules for React DOM
58 | reactDom.configs.recommended,
59 | ],
60 | languageOptions: {
61 | parserOptions: {
62 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
63 | tsconfigRootDir: import.meta.dirname,
64 | },
65 | // other options...
66 | },
67 | },
68 | ])
69 | ```
70 |
--------------------------------------------------------------------------------
/examples/with-vite/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | import ImageCaptureDialogDesktop from "./views/ImageCaptureDialogDesktop";
4 | import ImageCaptureDialogMobile from "./views/ImageCaptureDialogMobile";
5 | import { Button } from "./ui/components";
6 | import { Camera } from "lucide-react";
7 |
8 | function useIsMobile() {
9 | const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
10 |
11 | useEffect(() => {
12 | const handleResize = () => setIsMobile(window.innerWidth < 768);
13 | window.addEventListener("resize", handleResize);
14 | return () => window.removeEventListener("resize", handleResize);
15 | }, []);
16 |
17 | return isMobile;
18 | }
19 |
20 | function App() {
21 | const [open, setOpen] = useState(false);
22 |
23 | const isMobile = useIsMobile();
24 |
25 | const handleOpen = () => setOpen(true);
26 | const handleClose = () => setOpen(false);
27 |
28 | return (
29 |
30 | {/* Header */}
31 |
32 |
33 |
34 |
35 |
36 |
37 | React Web Camera
38 |
39 |
40 | Experience seamless image capture with our responsive camera
41 | component. Optimized for both desktop and mobile devices with
42 | intelligent UI adaptation.
43 |
44 |
45 |
46 | {/* Main Content */}
47 |
48 |
49 |
50 |
51 |
55 |
56 | Launch Camera
57 |
58 |
59 | {isMobile
60 | ? "Mobile-optimized interface"
61 | : "Desktop-enhanced experience"}
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | {isMobile ? (
70 |
71 | ) : (
72 |
73 | )}
74 |
75 | );
76 | }
77 |
78 | export default App;
79 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/content.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { Camera } from "lucide-react";
5 |
6 | import { Button } from "@/ui/components";
7 | import {
8 | ImageCaptureDialogDesktop,
9 | ImageCaptureDialogMobile,
10 | } from "@/app/components";
11 |
12 | function useIsMobile() {
13 | const [isMobile, setIsMobile] = useState(false);
14 |
15 | useEffect(() => {
16 | const checkMobile = () => setIsMobile(window.innerWidth < 768);
17 |
18 | checkMobile();
19 |
20 | window.addEventListener("resize", checkMobile);
21 |
22 | return () => window.removeEventListener("resize", checkMobile);
23 | }, []);
24 |
25 | return isMobile;
26 | }
27 |
28 | function Content() {
29 | const [open, setOpen] = useState(false);
30 |
31 | const isMobile = useIsMobile();
32 |
33 | const handleOpen = () => setOpen(true);
34 | const handleClose = () => setOpen(false);
35 |
36 | return (
37 |
38 | {/* Header */}
39 |
40 |
41 |
42 |
43 |
44 |
45 | React Web Camera
46 |
47 |
48 | Experience seamless image capture with our responsive camera
49 | component. Optimized for both desktop and mobile devices with
50 | intelligent UI adaptation.
51 |
52 |
53 |
54 | {/* Main Content */}
55 |
56 |
57 |
58 |
59 |
63 |
64 | Launch Camera
65 |
66 |
67 | {isMobile
68 | ? "Mobile-optimized interface"
69 | : "Desktop-enhanced experience"}
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {isMobile ? (
78 |
79 | ) : (
80 |
81 | )}
82 |
83 | );
84 | }
85 |
86 | export default Content;
87 |
--------------------------------------------------------------------------------
/www/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Geist, Geist_Mono } from "next/font/google";
3 | import Script from "next/script";
4 | import "./globals.css";
5 |
6 | const geistSans = Geist({
7 | variable: "--font-geist-sans",
8 | subsets: ["latin"],
9 | });
10 |
11 | const geistMono = Geist_Mono({
12 | variable: "--font-geist-mono",
13 | subsets: ["latin"],
14 | });
15 |
16 | const siteBase = "https://shivantra.com";
17 | const basePath = "/react-web-camera";
18 | const absolute = (p: string) => `${siteBase}${p}`;
19 |
20 | const isProd = process.env.NODE_ENV === "production";
21 | const GA_ID = "G-N43VRYBMJ0";
22 |
23 | export const metadata: Metadata = {
24 | metadataBase: new URL(siteBase),
25 | title: "React Web Camera – Shivantra",
26 | description:
27 | "A lightweight React component to capture images from the browser camera. Switch front/back, control quality, and use it in PWAs.",
28 | keywords: [
29 | "react webcam",
30 | "react web camera",
31 | "react capture image",
32 | "react webcam component",
33 | "react camera front back",
34 | "pwa camera react",
35 | "react-web-camera",
36 | ],
37 | authors: [
38 | {
39 | name: "Shivantra Solutions Private Limited",
40 | url: "https://shivantra.com",
41 | },
42 | ],
43 | creator: "Shivantra",
44 | publisher: "Shivantra Solutions",
45 | alternates: {
46 | canonical: absolute(`${basePath}/`),
47 | },
48 | openGraph: {
49 | title: "React Web Camera – Shivantra",
50 | description:
51 | "Capture images directly from the browser in React apps. Lightweight, mobile-friendly, and supports front/back switching.",
52 | url: absolute(`${basePath}/`),
53 | siteName: "Shivantra React Web Camera",
54 | images: [
55 | {
56 | url: absolute(`${basePath}/og/preview.png`),
57 | width: 1200,
58 | height: 630,
59 | alt: "React Web Camera demo preview",
60 | },
61 | ],
62 | locale: "en_US",
63 | type: "website",
64 | },
65 | twitter: {
66 | card: "summary_large_image",
67 | title: "React Web Camera – Shivantra",
68 | description:
69 | "React component to access the browser camera, capture images, and switch between cameras.",
70 | images: [absolute(`${basePath}/og/preview.png`)],
71 | },
72 | manifest: absolute(`${basePath}/manifest.json`),
73 | };
74 |
75 | export default function RootLayout({
76 | children,
77 | }: Readonly<{
78 | children: React.ReactNode;
79 | }>) {
80 | return (
81 |
82 |
85 | {children}
86 | {isProd && GA_ID && (
87 | <>
88 |
92 |
102 | >
103 | )}
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/examples/with-vite/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --radius-sm: calc(var(--radius) - 4px);
8 | --radius-md: calc(var(--radius) - 2px);
9 | --radius-lg: var(--radius);
10 | --radius-xl: calc(var(--radius) + 4px);
11 | --color-background: var(--background);
12 | --color-foreground: var(--foreground);
13 | --color-card: var(--card);
14 | --color-card-foreground: var(--card-foreground);
15 | --color-popover: var(--popover);
16 | --color-popover-foreground: var(--popover-foreground);
17 | --color-primary: var(--primary);
18 | --color-primary-foreground: var(--primary-foreground);
19 | --color-secondary: var(--secondary);
20 | --color-secondary-foreground: var(--secondary-foreground);
21 | --color-muted: var(--muted);
22 | --color-muted-foreground: var(--muted-foreground);
23 | --color-accent: var(--accent);
24 | --color-accent-foreground: var(--accent-foreground);
25 | --color-destructive: var(--destructive);
26 | --color-border: var(--border);
27 | --color-input: var(--input);
28 | --color-ring: var(--ring);
29 | --color-chart-1: var(--chart-1);
30 | --color-chart-2: var(--chart-2);
31 | --color-chart-3: var(--chart-3);
32 | --color-chart-4: var(--chart-4);
33 | --color-chart-5: var(--chart-5);
34 | --color-sidebar: var(--sidebar);
35 | --color-sidebar-foreground: var(--sidebar-foreground);
36 | --color-sidebar-primary: var(--sidebar-primary);
37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
38 | --color-sidebar-accent: var(--sidebar-accent);
39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
40 | --color-sidebar-border: var(--sidebar-border);
41 | --color-sidebar-ring: var(--sidebar-ring);
42 | }
43 |
44 | :root {
45 | --background: 0 0% 100%;
46 | --foreground: 222.2 84% 4.9%;
47 |
48 | --muted: 210 40% 96.1%;
49 | --muted-foreground: 215.4 16.3% 46.9%;
50 |
51 | --popover: 0 0% 100%;
52 | --popover-foreground: 222.2 84% 4.9%;
53 |
54 | --card: 0 0% 100%;
55 | --card-foreground: 222.2 84% 4.9%;
56 |
57 | --border: 214.3 31.8% 91.4%;
58 | --input: 214.3 31.8% 91.4%;
59 |
60 | --primary: 221.2 83.2% 53.3%;
61 | --primary-foreground: 210 40% 98%;
62 |
63 | --secondary: 210 40% 96.1%;
64 | --secondary-foreground: 222.2 47.4% 11.2%;
65 |
66 | --accent: 210 40% 96.1%;
67 | --accent-foreground: 222.2 47.4% 11.2%;
68 |
69 | --destructive: 0 72% 51%;
70 | --destructive-foreground: 210 40% 98%;
71 |
72 | --ring: 221.2 83.2% 53.3%;
73 |
74 | --radius: 0.5rem;
75 | }
76 |
77 | .dark {
78 | --background: 222.2 84% 4.9%;
79 | --foreground: 210 40% 98%;
80 |
81 | --muted: 217.2 32.6% 17.5%;
82 | --muted-foreground: 215 20.2% 65.1%;
83 |
84 | --popover: 222.2 84% 4.9%;
85 | --popover-foreground: 210 40% 98%;
86 |
87 | --card: 222.2 84% 4.9%;
88 | --card-foreground: 210 40% 98%;
89 |
90 | --border: 217.2 32.6% 17.5%;
91 | --input: 217.2 32.6% 17.5%;
92 |
93 | --primary: 217.2 91.2% 59.8%;
94 | --primary-foreground: 222.2 47.4% 11.2%;
95 |
96 | --secondary: 217.2 32.6% 17.5%;
97 | --secondary-foreground: 210 40% 98%;
98 |
99 | --accent: 217.2 32.6% 17.5%;
100 | --accent-foreground: 210 40% 98%;
101 |
102 | --destructive: 0 62.8% 30.6%;
103 | --destructive-foreground: 210 40% 98%;
104 |
105 | --ring: 224.3 76.3% 48%;
106 |
107 | --radius: 0.5rem;
108 | }
109 |
110 | @layer base {
111 | * {
112 | @apply border-border outline-ring/50;
113 | }
114 | body {
115 | @apply bg-background text-foreground;
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/www/app/content.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { Camera } from "lucide-react";
5 | import { Button } from "@/ui/components";
6 | import {
7 | ImageCaptureDialogDesktop,
8 | ImageCaptureDialogMobile,
9 | } from "@/app/components";
10 |
11 | function useIsMobile() {
12 | const [isMobile, setIsMobile] = useState(false);
13 | useEffect(() => {
14 | const checkMobile = () => setIsMobile(window.innerWidth < 768);
15 | checkMobile();
16 | window.addEventListener("resize", checkMobile);
17 | return () => window.removeEventListener("resize", checkMobile);
18 | }, []);
19 | return isMobile;
20 | }
21 |
22 | function Content() {
23 | const [open, setOpen] = useState(false);
24 | const isMobile = useIsMobile();
25 |
26 | const handleOpen = () => setOpen(true);
27 | const handleClose = () => setOpen(false);
28 |
29 | return (
30 |
31 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | React Web Camera
51 |
52 |
53 | Default camera inputs only allow one photo, making multi-image
54 | capture frustrating.
55 | React web camera is a lightweight, headless React
56 | component for capturing multiple images in one session. It’s
57 | flexible, PWA-friendly, and gives you full control over UI and
58 | styling.
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
70 |
71 | Launch Camera
72 |
73 |
74 | {isMobile
75 | ? "Mobile-optimized interface"
76 | : "Desktop-enhanced experience"}
77 |
78 |
79 |
80 |
81 |
82 |
93 |
94 |
95 |
96 | {isMobile ? (
97 |
98 | ) : (
99 |
100 | )}
101 |
102 | );
103 | }
104 |
105 | export default Content;
106 |
--------------------------------------------------------------------------------
/examples/with-vite/src/ui/components/dialog/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { XIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | showCloseButton = true,
51 | ...props
52 | }: React.ComponentProps & {
53 | showCloseButton?: boolean
54 | }) {
55 | return (
56 |
57 |
58 |
66 | {children}
67 | {showCloseButton && (
68 |
72 |
73 | Close
74 |
75 | )}
76 |
77 |
78 | )
79 | }
80 |
81 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
82 | return (
83 |
88 | )
89 | }
90 |
91 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
92 | return (
93 |
101 | )
102 | }
103 |
104 | function DialogTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function DialogDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Dialog,
132 | DialogClose,
133 | DialogContent,
134 | DialogDescription,
135 | DialogFooter,
136 | DialogHeader,
137 | DialogOverlay,
138 | DialogPortal,
139 | DialogTitle,
140 | DialogTrigger,
141 | }
142 |
--------------------------------------------------------------------------------
/www/ui/components/dialog/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | showCloseButton = true,
53 | ...props
54 | }: React.ComponentProps & {
55 | showCloseButton?: boolean
56 | }) {
57 | return (
58 |
59 |
60 |
68 | {children}
69 | {showCloseButton && (
70 |
74 |
75 | Close
76 |
77 | )}
78 |
79 |
80 | )
81 | }
82 |
83 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | )
91 | }
92 |
93 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94 | return (
95 |
103 | )
104 | }
105 |
106 | function DialogTitle({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | )
117 | }
118 |
119 | function DialogDescription({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | )
130 | }
131 |
132 | export {
133 | Dialog,
134 | DialogClose,
135 | DialogContent,
136 | DialogDescription,
137 | DialogFooter,
138 | DialogHeader,
139 | DialogOverlay,
140 | DialogPortal,
141 | DialogTitle,
142 | DialogTrigger,
143 | }
144 |
--------------------------------------------------------------------------------
/examples/with-nextjs/ui/components/dialog/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | showCloseButton = true,
53 | ...props
54 | }: React.ComponentProps & {
55 | showCloseButton?: boolean
56 | }) {
57 | return (
58 |
59 |
60 |
68 | {children}
69 | {showCloseButton && (
70 |
74 |
75 | Close
76 |
77 | )}
78 |
79 |
80 | )
81 | }
82 |
83 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | )
91 | }
92 |
93 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94 | return (
95 |
103 | )
104 | }
105 |
106 | function DialogTitle({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | )
117 | }
118 |
119 | function DialogDescription({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | )
130 | }
131 |
132 | export {
133 | Dialog,
134 | DialogClose,
135 | DialogContent,
136 | DialogDescription,
137 | DialogFooter,
138 | DialogHeader,
139 | DialogOverlay,
140 | DialogPortal,
141 | DialogTitle,
142 | DialogTrigger,
143 | }
144 |
--------------------------------------------------------------------------------
/www/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-geist-sans);
10 | --font-mono: var(--font-geist-mono);
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | }
45 |
46 | :root {
47 | --radius: 0.625rem;
48 | --background: oklch(1 0 0);
49 | --foreground: oklch(0.145 0 0);
50 | --card: oklch(1 0 0);
51 | --card-foreground: oklch(0.145 0 0);
52 | --popover: oklch(1 0 0);
53 | --popover-foreground: oklch(0.145 0 0);
54 | --primary: oklch(0.205 0 0);
55 | --primary-foreground: oklch(0.985 0 0);
56 | --secondary: oklch(0.97 0 0);
57 | --secondary-foreground: oklch(0.205 0 0);
58 | --muted: oklch(0.97 0 0);
59 | --muted-foreground: oklch(0.556 0 0);
60 | --accent: oklch(0.97 0 0);
61 | --accent-foreground: oklch(0.205 0 0);
62 | --destructive: oklch(0.577 0.245 27.325);
63 | --border: oklch(0.922 0 0);
64 | --input: oklch(0.922 0 0);
65 | --ring: oklch(0.708 0 0);
66 | --chart-1: oklch(0.646 0.222 41.116);
67 | --chart-2: oklch(0.6 0.118 184.704);
68 | --chart-3: oklch(0.398 0.07 227.392);
69 | --chart-4: oklch(0.828 0.189 84.429);
70 | --chart-5: oklch(0.769 0.188 70.08);
71 | --sidebar: oklch(0.985 0 0);
72 | --sidebar-foreground: oklch(0.145 0 0);
73 | --sidebar-primary: oklch(0.205 0 0);
74 | --sidebar-primary-foreground: oklch(0.985 0 0);
75 | --sidebar-accent: oklch(0.97 0 0);
76 | --sidebar-accent-foreground: oklch(0.205 0 0);
77 | --sidebar-border: oklch(0.922 0 0);
78 | --sidebar-ring: oklch(0.708 0 0);
79 | }
80 |
81 | .dark {
82 | --background: oklch(0.145 0 0);
83 | --foreground: oklch(0.985 0 0);
84 | --card: oklch(0.205 0 0);
85 | --card-foreground: oklch(0.985 0 0);
86 | --popover: oklch(0.205 0 0);
87 | --popover-foreground: oklch(0.985 0 0);
88 | --primary: oklch(0.922 0 0);
89 | --primary-foreground: oklch(0.205 0 0);
90 | --secondary: oklch(0.269 0 0);
91 | --secondary-foreground: oklch(0.985 0 0);
92 | --muted: oklch(0.269 0 0);
93 | --muted-foreground: oklch(0.708 0 0);
94 | --accent: oklch(0.269 0 0);
95 | --accent-foreground: oklch(0.985 0 0);
96 | --destructive: oklch(0.704 0.191 22.216);
97 | --border: oklch(1 0 0 / 10%);
98 | --input: oklch(1 0 0 / 15%);
99 | --ring: oklch(0.556 0 0);
100 | --chart-1: oklch(0.488 0.243 264.376);
101 | --chart-2: oklch(0.696 0.17 162.48);
102 | --chart-3: oklch(0.769 0.188 70.08);
103 | --chart-4: oklch(0.627 0.265 303.9);
104 | --chart-5: oklch(0.645 0.246 16.439);
105 | --sidebar: oklch(0.205 0 0);
106 | --sidebar-foreground: oklch(0.985 0 0);
107 | --sidebar-primary: oklch(0.488 0.243 264.376);
108 | --sidebar-primary-foreground: oklch(0.985 0 0);
109 | --sidebar-accent: oklch(0.269 0 0);
110 | --sidebar-accent-foreground: oklch(0.985 0 0);
111 | --sidebar-border: oklch(1 0 0 / 10%);
112 | --sidebar-ring: oklch(0.556 0 0);
113 | }
114 |
115 | @layer base {
116 | * {
117 | @apply border-border outline-ring/50;
118 | }
119 | body {
120 | @apply bg-background text-foreground;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-geist-sans);
10 | --font-mono: var(--font-geist-mono);
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | }
45 |
46 | :root {
47 | --radius: 0.625rem;
48 | --background: oklch(1 0 0);
49 | --foreground: oklch(0.145 0 0);
50 | --card: oklch(1 0 0);
51 | --card-foreground: oklch(0.145 0 0);
52 | --popover: oklch(1 0 0);
53 | --popover-foreground: oklch(0.145 0 0);
54 | --primary: oklch(0.205 0 0);
55 | --primary-foreground: oklch(0.985 0 0);
56 | --secondary: oklch(0.97 0 0);
57 | --secondary-foreground: oklch(0.205 0 0);
58 | --muted: oklch(0.97 0 0);
59 | --muted-foreground: oklch(0.556 0 0);
60 | --accent: oklch(0.97 0 0);
61 | --accent-foreground: oklch(0.205 0 0);
62 | --destructive: oklch(0.577 0.245 27.325);
63 | --border: oklch(0.922 0 0);
64 | --input: oklch(0.922 0 0);
65 | --ring: oklch(0.708 0 0);
66 | --chart-1: oklch(0.646 0.222 41.116);
67 | --chart-2: oklch(0.6 0.118 184.704);
68 | --chart-3: oklch(0.398 0.07 227.392);
69 | --chart-4: oklch(0.828 0.189 84.429);
70 | --chart-5: oklch(0.769 0.188 70.08);
71 | --sidebar: oklch(0.985 0 0);
72 | --sidebar-foreground: oklch(0.145 0 0);
73 | --sidebar-primary: oklch(0.205 0 0);
74 | --sidebar-primary-foreground: oklch(0.985 0 0);
75 | --sidebar-accent: oklch(0.97 0 0);
76 | --sidebar-accent-foreground: oklch(0.205 0 0);
77 | --sidebar-border: oklch(0.922 0 0);
78 | --sidebar-ring: oklch(0.708 0 0);
79 | }
80 |
81 | .dark {
82 | --background: oklch(0.145 0 0);
83 | --foreground: oklch(0.985 0 0);
84 | --card: oklch(0.205 0 0);
85 | --card-foreground: oklch(0.985 0 0);
86 | --popover: oklch(0.205 0 0);
87 | --popover-foreground: oklch(0.985 0 0);
88 | --primary: oklch(0.922 0 0);
89 | --primary-foreground: oklch(0.205 0 0);
90 | --secondary: oklch(0.269 0 0);
91 | --secondary-foreground: oklch(0.985 0 0);
92 | --muted: oklch(0.269 0 0);
93 | --muted-foreground: oklch(0.708 0 0);
94 | --accent: oklch(0.269 0 0);
95 | --accent-foreground: oklch(0.985 0 0);
96 | --destructive: oklch(0.704 0.191 22.216);
97 | --border: oklch(1 0 0 / 10%);
98 | --input: oklch(1 0 0 / 15%);
99 | --ring: oklch(0.556 0 0);
100 | --chart-1: oklch(0.488 0.243 264.376);
101 | --chart-2: oklch(0.696 0.17 162.48);
102 | --chart-3: oklch(0.769 0.188 70.08);
103 | --chart-4: oklch(0.627 0.265 303.9);
104 | --chart-5: oklch(0.645 0.246 16.439);
105 | --sidebar: oklch(0.205 0 0);
106 | --sidebar-foreground: oklch(0.985 0 0);
107 | --sidebar-primary: oklch(0.488 0.243 264.376);
108 | --sidebar-primary-foreground: oklch(0.985 0 0);
109 | --sidebar-accent: oklch(0.269 0 0);
110 | --sidebar-accent-foreground: oklch(0.985 0 0);
111 | --sidebar-border: oklch(1 0 0 / 10%);
112 | --sidebar-ring: oklch(0.556 0 0);
113 | }
114 |
115 | @layer base {
116 | * {
117 | @apply border-border outline-ring/50;
118 | }
119 | body {
120 | @apply bg-background text-foreground;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/WebCamera.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | forwardRef,
3 | useCallback,
4 | useEffect,
5 | useImperativeHandle,
6 | useRef,
7 | useState,
8 | } from "react";
9 |
10 | export type CaptureType = "jpeg" | "png" | "webp";
11 | export type CaptureQuality =
12 | | 0.1
13 | | 0.2
14 | | 0.3
15 | | 0.4
16 | | 0.5
17 | | 0.6
18 | | 0.7
19 | | 0.8
20 | | 0.9
21 | | 1;
22 | export type CaptureMode = "front" | "back";
23 | export type FacingMode = "user" | "environment";
24 |
25 | export interface WebCameraProps {
26 | className?: string;
27 | style?: React.CSSProperties;
28 | videoClassName?: string;
29 | videoStyle?: React.CSSProperties;
30 | getFileName?: () => string;
31 | captureMode?: CaptureMode;
32 | captureType?: CaptureType;
33 | captureQuality?: CaptureQuality;
34 | onError?: (err: Error) => void;
35 | }
36 |
37 | export type WebCameraHandler = {
38 | capture: () => Promise;
39 | switch: (facingMode?: FacingMode) => Promise;
40 | getMode: () => CaptureMode;
41 | };
42 |
43 | const CAPTURE_MODES: Record = {
44 | back: "environment",
45 | front: "user",
46 | };
47 |
48 | export const WebCamera = forwardRef(
49 | (
50 | {
51 | className,
52 | style,
53 | videoClassName,
54 | videoStyle,
55 | getFileName,
56 | captureMode = "back",
57 | captureType = "jpeg",
58 | captureQuality = 0.8,
59 | onError,
60 | },
61 | ref,
62 | ) => {
63 | const videoRef = useRef(null);
64 | const canvasRef = useRef(null);
65 | const [stream, setStream] = useState(null);
66 | const [facingMode, setFacingMode] = useState(
67 | CAPTURE_MODES[captureMode],
68 | );
69 | const [devices, setDevices] = useState([]);
70 |
71 | const captureImage = useCallback(async () => {
72 | const video = videoRef.current;
73 | const canvas = canvasRef.current;
74 |
75 | if (!video || !canvas) return null;
76 | if (video.readyState < 2) return null; // not ready yet
77 |
78 | return new Promise((resolve) => {
79 | const context = canvas.getContext("2d")!;
80 |
81 | const width = video.videoWidth || 640;
82 | const height = video.videoHeight || 480;
83 | canvas.width = width;
84 | canvas.height = height;
85 |
86 | context.drawImage(video, 0, 0, width, height);
87 |
88 | const imageType = `image/${captureType}`;
89 |
90 | canvas.toBlob(
91 | async (blob) => {
92 | if (!blob) return;
93 |
94 | const file = new File(
95 | [blob],
96 | getFileName?.() ?? `capture-${Date.now()}.${captureType}`,
97 | {
98 | type: imageType,
99 | lastModified: Date.now(),
100 | },
101 | );
102 |
103 | resolve(file);
104 | },
105 | imageType,
106 | captureQuality,
107 | );
108 | });
109 | }, [captureType, captureQuality, getFileName]);
110 |
111 | const switchCamera = useCallback(async () => {
112 | if (stream) {
113 | stream.getTracks().forEach((track) => track.stop());
114 | if (videoRef.current) videoRef.current.srcObject = null;
115 | }
116 |
117 | const newFacingMode = facingMode === "user" ? "environment" : "user";
118 | setFacingMode(newFacingMode);
119 |
120 | try {
121 | let constraints: MediaStreamConstraints;
122 |
123 | // fallback: if facingMode not supported, use deviceId
124 | if (devices.length >= 2) {
125 | const device = devices.find((d) =>
126 | newFacingMode === "user"
127 | ? d.label.toLowerCase().includes("front")
128 | : d.label.toLowerCase().includes("back"),
129 | );
130 |
131 | constraints = device
132 | ? { video: { deviceId: { exact: device.deviceId } } }
133 | : { video: { facingMode: { ideal: newFacingMode } } };
134 | } else {
135 | constraints = { video: { facingMode: { ideal: newFacingMode } } };
136 | }
137 |
138 | const newStream =
139 | await navigator.mediaDevices.getUserMedia(constraints);
140 | if (videoRef.current) videoRef.current.srcObject = newStream;
141 | setStream(newStream);
142 | } catch (err) {
143 | onError?.(err as Error);
144 | }
145 | }, [stream, facingMode, devices, onError]);
146 |
147 | useImperativeHandle(
148 | ref,
149 | () => ({
150 | capture: captureImage,
151 | switch: switchCamera,
152 | getMode: () => (facingMode === "environment" ? "back" : "front"),
153 | }),
154 | [facingMode, captureImage, switchCamera],
155 | );
156 |
157 | useEffect(() => {
158 | let mediaStream: MediaStream;
159 |
160 | const video = videoRef.current;
161 |
162 | const initCamera = async () => {
163 | try {
164 | // enumerate devices (helps iOS Safari + others)
165 | const allDevices = await navigator.mediaDevices.enumerateDevices();
166 | setDevices(allDevices.filter((d) => d.kind === "videoinput"));
167 |
168 | mediaStream = await navigator.mediaDevices.getUserMedia({
169 | video: { facingMode: { ideal: facingMode } },
170 | });
171 |
172 | if (video) {
173 | video.srcObject = mediaStream;
174 | video.onloadedmetadata = () => video?.play();
175 | }
176 | setStream(mediaStream);
177 | } catch (err) {
178 | onError?.(err as Error);
179 | }
180 | };
181 |
182 | initCamera();
183 |
184 | return () => {
185 | mediaStream?.getTracks().forEach((track) => track.stop());
186 | if (video) {
187 | video.srcObject = null;
188 | }
189 | };
190 | }, [facingMode, onError]);
191 |
192 | return (
193 |
194 |
208 | Video stream not available.
209 |
210 |
211 |
212 | );
213 | },
214 | );
215 |
--------------------------------------------------------------------------------
/www/public/sw.js:
--------------------------------------------------------------------------------
1 | if(!self.define){let e,a={};const c=(c,s)=>(c=new URL(c+".js",s).href,a[c]||new Promise(a=>{if("document"in self){const e=document.createElement("script");e.src=c,e.onload=a,document.head.appendChild(e)}else e=c,importScripts(c),a()}).then(()=>{let e=a[c];if(!e)throw new Error(`Module ${c} didn’t register its module`);return e}));self.define=(s,t)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(a[n])return;let i={};const r=e=>c(e,n),f={module:{uri:n},exports:i,require:r};a[n]=Promise.all(s.map(e=>f[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-8ad7dfbc"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/react-web-camera/_next/static/OAR5K7IylO0BhnVPTrBtG/_buildManifest.js",revision:"6cedb4275d21d9ae9dc83437430a1b5c"},{url:"/react-web-camera/_next/static/OAR5K7IylO0BhnVPTrBtG/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/react-web-camera/_next/static/chunks/520-796364b33448c7f9.js",revision:"796364b33448c7f9"},{url:"/react-web-camera/_next/static/chunks/981-4023fae46e128634.js",revision:"4023fae46e128634"},{url:"/react-web-camera/_next/static/chunks/app/_not-found/page-0867fb6c7e2d6b98.js",revision:"0867fb6c7e2d6b98"},{url:"/react-web-camera/_next/static/chunks/app/layout-f0081453ed287003.js",revision:"f0081453ed287003"},{url:"/react-web-camera/_next/static/chunks/app/page-bbde6a6eb6964ae2.js",revision:"bbde6a6eb6964ae2"},{url:"/react-web-camera/_next/static/chunks/dd098d2d-0f72804197df84b1.js",revision:"0f72804197df84b1"},{url:"/react-web-camera/_next/static/chunks/framework-849bdcb52d5d37d6.js",revision:"849bdcb52d5d37d6"},{url:"/react-web-camera/_next/static/chunks/main-7052f12a7df9ca79.js",revision:"7052f12a7df9ca79"},{url:"/react-web-camera/_next/static/chunks/main-app-d27f4b812b5e8a99.js",revision:"d27f4b812b5e8a99"},{url:"/react-web-camera/_next/static/chunks/pages/_app-a3ca044f39560f99.js",revision:"a3ca044f39560f99"},{url:"/react-web-camera/_next/static/chunks/pages/_error-33ded00fffe34d4a.js",revision:"33ded00fffe34d4a"},{url:"/react-web-camera/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/react-web-camera/_next/static/chunks/webpack-f37f3b65da78914b.js",revision:"f37f3b65da78914b"},{url:"/react-web-camera/_next/static/css/2d6b997afa552db4.css",revision:"2d6b997afa552db4"},{url:"/react-web-camera/_next/static/media/569ce4b8f30dc480-s.p.woff2",revision:"ef6cefb32024deac234e82f932a95cbd"},{url:"/react-web-camera/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/react-web-camera/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/react-web-camera/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/react-web-camera/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/react-web-camera/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/react-web-camera/github.svg",revision:"83846e47ab54def74f675fca95a45064"},{url:"/react-web-camera/icon-192x192.png",revision:"f08af455a7aaad817ed9fa00136ff26f"},{url:"/react-web-camera/icon-512x512.png",revision:"fdb8e900f8ccf820f013e55897e819c2"},{url:"/react-web-camera/manifest.json",revision:"4c9e7dbd4da3961f335f307d56a5cb1a"},{url:"/react-web-camera/og/preview.png",revision:"b3e3e49656027ca231ca7c8581a73ffa"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute("/react-web-camera",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:function(e){var a=e.response;return _async_to_generator(function(){return _ts_generator(this,function(e){return[2,a&&"opaqueredirect"===a.type?new Response(a.body,{status:200,statusText:"OK",headers:a.headers}):a]})})()}}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/_next\/static.+\.js$/i,new e.CacheFirst({cacheName:"next-static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4|webm)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:48,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(function(e){var a=e.sameOrigin,c=e.url.pathname;return!(!a||c.startsWith("/api/auth/callback")||!c.startsWith("/api/"))},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(function(e){var a=e.request,c=e.url.pathname,s=e.sameOrigin;return"1"===a.headers.get("RSC")&&"1"===a.headers.get("Next-Router-Prefetch")&&s&&!c.startsWith("/api/")},new e.NetworkFirst({cacheName:"pages-rsc-prefetch",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(function(e){var a=e.request,c=e.url.pathname,s=e.sameOrigin;return"1"===a.headers.get("RSC")&&s&&!c.startsWith("/api/")},new e.NetworkFirst({cacheName:"pages-rsc",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(function(e){var a=e.url.pathname;return e.sameOrigin&&!a.startsWith("/api/")},new e.NetworkFirst({cacheName:"pages",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(function(e){return!e.sameOrigin},new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Web Camera
2 |
3 | A lightweight and flexible React component for capturing images from the user’s camera (front or back) with support for `jpeg`, `png`, and `webp` formats. Built with modern React (`hooks` + `forwardRef`) and works on both desktop and mobile browsers.
4 |
5 | ## Table of Contents
6 |
7 | - [Why?](#why-)
8 | - [Our Solution](#our-solution-)
9 | - [Features](#features-)
10 | - [Installation](#installation-)
11 | - [Usage](#usage-)
12 | - [Basic Example](#basic-example)
13 | - [Vite.js Example](#vitejs-example)
14 | - [Next.js Example (App Router)](#nextjs-example-app-router)
15 | - [PWA Example](#pwa-example)
16 | - [Props](#props-%EF%B8%8F)
17 | - [Ref Methods](#ref-methods-)
18 | - [Notes](#notes-%EF%B8%8F)
19 | - [License](#license-)
20 | - [Contact](#contact-)
21 |
22 | ---
23 |
24 | ## Why?
25 |
26 | Capturing multiple images from a webcam is a common need in modern web apps, especially Progressive Web Apps (PWAs).
27 | Existing solutions are often:
28 |
29 | - Single-shot only (cannot capture multiple images)
30 | - Bloated or heavy
31 | - Hard to customize UI or styling
32 | - Not fully compatible with PWAs or mobile browsers
33 |
34 | **Problem with ` ` on mobile:**
35 | When you use a file input like:
36 |
37 | ```html
38 |
39 | ```
40 |
41 | on phones, it only allows **a single photo capture**. After you take one photo, the camera closes, and to capture another, the user must reopen the camera again.
42 | This creates a poor user experience for apps needing **multi-photo sessions** (for example: KYC verification, delivery apps, or documentation workflows).
43 |
44 | ---
45 |
46 | ## Our Solution
47 |
48 | `react-web-camera` provides a headless, platform-independent React component that gives you full control over your UI. It handles the complex logic of accessing the webcam, capturing multiple images, and managing state, while you focus on styling and user experience.
49 |
50 | This makes it:
51 |
52 | - **Lightweight** – minimal overhead for fast, responsive apps
53 | - **Flexible** – integrate seamlessly with your design system
54 | - **Multi-Image Ready** – capture and manage multiple photos in a single session
55 |
56 | ---
57 |
58 | ## Features
59 |
60 | - **📷 Front & Back Camera Support** – Easily capture images from both cameras.
61 | - **🖼 Multiple Image Formats** – Export images as jpeg, png, or webp.
62 | - **⚡ Adjustable Capture Quality** – Control image quality with a range of 0.1–1.0.
63 | - **🔄 Camera Switching** – Seamlessly switch between front (user) and back (environment) cameras.
64 | - **📸 Multi-Image Capture** – Click and manage multiple pictures within a session on both web and mobile.
65 | - **🎯 Camera Ready on Mount** – Access the camera instantly when the component loads.
66 | - **🛠 Full Programmatic Control** – Use ref methods like capture(), switch(), and getMode().
67 | - **🎨 Custom Styling** – Style the container and video element to match your design system.
68 |
69 | ---
70 |
71 | ## Installation
72 |
73 | ```bash
74 | # If using npm
75 | npm install @shivantra/react-web-camera
76 | ```
77 |
78 | ```bash
79 | # Or with yarn
80 | yarn add @shivantra/react-web-camera
81 | ```
82 |
83 | ```bash
84 | # Or with pnpm
85 | pnpm add @shivantra/react-web-camera
86 | ```
87 |
88 | ---
89 |
90 | ## Usage
91 |
92 | - **Basic Example**
93 |
94 | ```tsx
95 | import React, { useRef } from "react";
96 | import { WebCamera, WebCameraHandler } from "@shivantra/react-web-camera";
97 |
98 | function App() {
99 | const cameraHandler = useRef(null);
100 | const [images, setImages] = useState([]);
101 |
102 | async function handleCapture() {
103 | const file = await cameraHandler.current?.capture();
104 | if (file) {
105 | const base64 = await fileToBase64(file);
106 | setImages((_images) => [..._images, base64]);
107 | }
108 | }
109 |
110 | function handleSwitch() {
111 | cameraHandler.current?.switch();
112 | }
113 |
114 | return (
115 |
116 |
117 | Capture
118 | Switch
119 |
120 |
121 |
127 |
128 |
129 | {images.map((image, ind) => (
130 |
131 | ))}
132 |
133 |
134 | );
135 | }
136 | ```
137 |
138 | - **Vite.js Example**
139 |
140 | ```tsx
141 | import React from "react";
142 | import ReactDOM from "react-dom/client";
143 | import { WebCamera } from "@shivantra/react-web-camera";
144 |
145 | function App() {
146 | return (
147 |
148 |
📸 Vite + Webcam
149 | `vite-photo-${Date.now()}.jpeg`}
157 | />
158 |
159 | );
160 | }
161 |
162 | ReactDOM.createRoot(document.getElementById("root")!).render( );
163 | ```
164 |
165 | - **Next.js Example (App Router)**
166 |
167 | ```tsx
168 | "use client";
169 |
170 | import { WebCamera } from "@shivantra/react-web-camera";
171 |
172 | export default function CameraPage() {
173 | return (
174 |
175 | 📸 Next.js Webcam Example
176 | `next-photo-${Date.now()}.jpeg`}
183 | onError={(err) => console.error(err)}
184 | />
185 |
186 | );
187 | }
188 | ```
189 |
190 | - **PWA Example**
191 |
192 | ```tsx
193 | import { WebCamera } from "@shivantra/react-web-camera";
194 |
195 | export default function PWAApp() {
196 | return (
197 |
198 |
📱 PWA Webcam Ready
199 | `pwa-photo-${Date.now()}.jpeg`}
208 | onError={(err) => console.error(err)}
209 | />
210 |
211 | );
212 | }
213 | ```
214 |
215 | > ✅ Works on mobile browsers and when installed as a PWA (HTTPS required for camera access).
216 |
217 | ---
218 |
219 | ## Props ⚙️
220 |
221 | | Prop | Type | Default | Description |
222 | | ---------------- | ------------------------------- | -------- | ------------------------------------------------ |
223 | | `className` | `string` | — | CSS class for the wrapper `` |
224 | | `style` | `React.CSSProperties` | — | Inline styles for the wrapper `
` |
225 | | `videoClassName` | `string` | — | CSS class for the `
` element |
226 | | `videoStyle` | `React.CSSProperties` | — | Inline styles for the `` element |
227 | | `getFileName` | `() => string` | — | Optional function to generate captured file name |
228 | | `captureMode` | `"front"` \| `"back"` | `"back"` | Initial camera mode |
229 | | `captureType` | `"jpeg"` \| `"png"` \| `"webp"` | `"jpeg"` | Image format for capture |
230 | | `captureQuality` | `0.1`–`1.0` | `0.8` | Image quality for capture |
231 | | `onError` | `(err: Error) => void` | — | Callback for camera errors |
232 |
233 | ---
234 |
235 | ## Ref Methods
236 |
237 | Access these methods via `ref`:
238 |
239 | | Method | Description |
240 | | ----------- | -------------------------------------------------------------- |
241 | | `capture()` | Captures an image from the camera and returns a `File` object. |
242 | | `switch()` | Switches between front and back cameras. |
243 | | `getMode()` | Returns current camera mode: `"front"` or `"back"`. |
244 |
245 | ---
246 |
247 | ## Notes
248 |
249 | - On mobile devices, some browsers may require HTTPS to access the camera.
250 | - Ensure the user grants camera permissions; otherwise, the component will throw an error.
251 | - `videoStyle` and `style` are independent — `videoStyle` only affects the video element, `style` affects the container.
252 |
253 | ---
254 |
255 | ## License
256 |
257 | MIT License © 2025 Shivantra Solutions Private Limited
258 |
259 | ---
260 |
261 | ## Contact
262 |
263 | For more details about our projects, services, or any general information regarding **react-web-camera**, feel free to reach out to us. We are here to provide support and answer any questions you may have. Below are the best ways to contact our team:
264 |
265 | **Email:** Send us your inquiries or support requests at [contact@shivantra.com](mailto:contact@shivantra.com).
266 | **Website:** Visit our official website for more information: [Shivantra](https://shivantra.com).
267 |
268 | **Follow us on social media for updates:**
269 |
270 | - **LinkedIn:** [Shivantra](https://www.linkedin.com/company/shivantra)
271 | - **Instagram:** [@Shivantra](https://www.instagram.com/shivantra/)
272 | - **Github:** [Shivantra](https://www.github.com/shivantra/)
273 |
274 | We look forward to assisting you and ensuring your experience with **react-web-camera** is smooth and enjoyable!
275 |
--------------------------------------------------------------------------------
/examples/with-vite/src/views/ImageCaptureDialogDesktop.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Dialog, DialogContent, DialogTitle } from "@/ui/components";
2 | import {
3 | Camera,
4 | CameraOff,
5 | ImageIcon,
6 | Loader2,
7 | RefreshCcw,
8 | X,
9 | } from "lucide-react";
10 | import { useRef, useState } from "react";
11 | import WebCamera from "@shivantra/react-web-camera";
12 | import type { FacingMode, WebCameraHandler } from "@shivantra/react-web-camera";
13 |
14 | interface Image {
15 | url: string;
16 | file: File;
17 | }
18 |
19 | export function ImageCaptureDialogDesktop({
20 | open,
21 | onOpenChange,
22 | }: {
23 | open: boolean;
24 | onOpenChange: () => void;
25 | }) {
26 | const [images, setImages] = useState([]);
27 | const [isSaving, setIsSaving] = useState(false);
28 | const [facingMode, setFacingMode] = useState("environment");
29 | const [cameraError, setCameraError] = useState(false);
30 |
31 | const cameraRef = useRef(null);
32 |
33 | /**
34 | * Removes an image from the gallery based on its index.
35 | * @param index The index of the image to be deleted.
36 | */
37 |
38 | const deleteImage = (index: number) => {
39 | setImages((prev) => prev.filter((_, i) => i !== index));
40 | };
41 |
42 | /**
43 | * Handles the save operation. It simulates an asynchronous upload process.
44 | * In a real application, this is where you would perform an API call.
45 | */
46 | const handleSave = async () => {
47 | if (images.length === 0) return;
48 | setIsSaving(true);
49 | try {
50 | const files = images.map((image) => image.file);
51 |
52 | // This Promise simulates a network request, like an API call to upload the files.
53 | // Replace this with your actual save logic (e.g., using fetch or axios).
54 | await new Promise((resolve) => {
55 | setTimeout(() => {
56 | console.log("Saved files:", files);
57 | resolve();
58 | }, 3000);
59 | });
60 |
61 | setImages([]);
62 | onOpenChange?.();
63 | } catch (error) {
64 | console.error("Failed to save images:", error);
65 | // You could add an error state here to show an alert to the user.
66 | } finally {
67 | setIsSaving(false);
68 | }
69 | };
70 |
71 | /**
72 | * Handles the dialog close action. It prompts the user for confirmation
73 | * if there are unsaved images to prevent data loss.
74 | */
75 | const handleClose = () => {
76 | if (images.length > 0 && !isSaving) {
77 | if (
78 | !window.confirm(
79 | "You have unsaved images. Are you sure you want to close?"
80 | )
81 | ) {
82 | return;
83 | }
84 | }
85 | setImages([]);
86 | onOpenChange?.();
87 | };
88 |
89 | /**
90 | * Captures an image from the webcam and adds it to the gallery.
91 | */
92 | const handleCapture = async () => {
93 | if (!cameraRef.current) return;
94 | try {
95 | const file = await cameraRef.current.capture();
96 | if (file) {
97 | const url = URL.createObjectURL(file);
98 | setImages((prev) => [...prev, { url, file }]);
99 | }
100 | } catch (error) {
101 | console.error("Capture error:", error);
102 | }
103 | };
104 |
105 | /**
106 | * Switches the camera between front-facing ('user') and back-facing ('environment').
107 | */
108 | const handleCameraSwitch = async () => {
109 | if (!cameraRef.current) return;
110 | try {
111 | const newMode = facingMode === "user" ? "environment" : "user";
112 | await cameraRef.current.switch(newMode);
113 | setFacingMode(newMode);
114 | } catch (error) {
115 | console.error("Camera switch error:", error);
116 | }
117 | };
118 |
119 | return (
120 |
121 |
122 |
123 |
124 | {/* Camera Section */}
125 |
126 | {cameraError ? (
127 |
128 |
129 |
Camera not available or permission denied.
130 |
Please check your camera settings.
131 |
132 | ) : (
133 |
`capture-${Date.now()}.jpeg`}
143 | onError={(err) => {
144 | console.error("Camera error:", err);
145 | setCameraError(true);
146 | }}
147 | />
148 | )}
149 |
150 | {/* Capture Controls */}
151 | {!cameraError && (
152 |
153 |
159 |
160 |
161 |
169 |
174 |
175 |
176 | )}
177 |
178 |
179 | {/* Gallery Section */}
180 |
181 | {/* Header */}
182 |
183 |
184 |
185 |
Gallery
186 |
187 | {images.length} {images.length === 1 ? "image" : "images"}
188 |
189 |
190 |
191 |
192 | {/* Scrollable grid */}
193 |
194 | {images.length === 0 ? (
195 |
196 |
197 |
198 | No images captured yet
199 |
200 |
201 | ) : (
202 |
203 | {images.map((image, index) => (
204 |
205 |
206 |
211 |
212 |
deleteImage(index)}
214 | aria-label={`Delete image ${index + 1}`}
215 | className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-lg cursor-pointer"
216 | >
217 |
218 |
219 |
220 | ))}
221 |
222 | )}
223 |
224 |
225 | {/* footer buttons */}
226 |
227 |
233 | Cancel
234 |
235 |
240 | {isSaving ? (
241 | <>
242 |
243 | Saving...
244 | >
245 | ) : (
246 | <>
247 | Save
248 | {images.length > 0 && `(${images.length})`}
249 | >
250 | )}
251 |
252 |
253 |
254 |
255 | {/* Loading Overlay */}
256 | {isSaving && (
257 |
258 |
259 |
260 |
261 | Saving images...
262 |
263 |
264 |
265 | )}
266 |
267 |
268 |
269 | );
270 | }
271 |
272 | export default ImageCaptureDialogDesktop;
273 |
--------------------------------------------------------------------------------
/www/app/components/image-capture-dialog-desktop/image-capture-dialog-desktop.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Camera,
5 | CameraOff,
6 | ImageIcon,
7 | Loader2,
8 | RefreshCcw,
9 | X,
10 | } from "lucide-react";
11 | import { useRef, useState } from "react";
12 | import WebCamera from "@shivantra/react-web-camera";
13 | import type { FacingMode, WebCameraHandler } from "@shivantra/react-web-camera";
14 |
15 | import { Button, Dialog, DialogContent, DialogTitle } from "@/ui/components";
16 |
17 | interface Image {
18 | url: string;
19 | file: File;
20 | }
21 |
22 | export function ImageCaptureDialogDesktop({
23 | open,
24 | onOpenChange,
25 | }: {
26 | open: boolean;
27 | onOpenChange: () => void;
28 | }) {
29 | const [images, setImages] = useState([]);
30 | const [isSaving, setIsSaving] = useState(false);
31 | const [facingMode, setFacingMode] = useState("environment");
32 | const [cameraError, setCameraError] = useState(false);
33 |
34 | const cameraRef = useRef(null);
35 |
36 | /**
37 | * Removes an image from the gallery based on its index.
38 | * @param index The index of the image to be deleted.
39 | */
40 |
41 | const deleteImage = (index: number) => {
42 | setImages((prev) => prev.filter((_, i) => i !== index));
43 | };
44 |
45 | /**
46 | * Handles the save operation. It simulates an asynchronous upload process.
47 | * In a real application, this is where you would perform an API call.
48 | */
49 | const handleSave = async () => {
50 | if (images.length === 0) return;
51 | setIsSaving(true);
52 | try {
53 | const files = images.map((image) => image.file);
54 |
55 | // This Promise simulates a network request, like an API call to upload the files.
56 | // Replace this with your actual save logic (e.g., using fetch or axios).
57 | await new Promise((resolve) => {
58 | setTimeout(() => {
59 | console.log("Saved files:", files);
60 | resolve();
61 | }, 3000);
62 | });
63 |
64 | setImages([]);
65 | onOpenChange?.();
66 | } catch (error) {
67 | console.error("Failed to save images:", error);
68 | // You could add an error state here to show an alert to the user.
69 | } finally {
70 | setIsSaving(false);
71 | }
72 | };
73 |
74 | /**
75 | * Handles the dialog close action. It prompts the user for confirmation
76 | * if there are unsaved images to prevent data loss.
77 | */
78 | const handleClose = () => {
79 | if (images.length > 0 && !isSaving) {
80 | if (
81 | !window.confirm(
82 | "You have unsaved images. Are you sure you want to close?"
83 | )
84 | ) {
85 | return;
86 | }
87 | }
88 | setImages([]);
89 | onOpenChange?.();
90 | };
91 |
92 | /**
93 | * Captures an image from the webcam and adds it to the gallery.
94 | */
95 | const handleCapture = async () => {
96 | if (!cameraRef.current) return;
97 | try {
98 | const file = await cameraRef.current.capture();
99 | if (file) {
100 | const url = URL.createObjectURL(file);
101 | setImages((prev) => [...prev, { url, file }]);
102 | }
103 | } catch (error) {
104 | console.error("Capture error:", error);
105 | }
106 | };
107 |
108 | /**
109 | * Switches the camera between front-facing ('user') and back-facing ('environment').
110 | */
111 | const handleCameraSwitch = async () => {
112 | if (!cameraRef.current) return;
113 | try {
114 | const newMode = facingMode === "user" ? "environment" : "user";
115 | await cameraRef.current.switch(newMode);
116 | setFacingMode(newMode);
117 | } catch (error) {
118 | console.error("Camera switch error:", error);
119 | }
120 | };
121 |
122 | return (
123 |
124 |
125 |
126 |
127 | {/* Camera Section */}
128 |
129 | {cameraError ? (
130 |
131 |
132 |
Camera not available or permission denied.
133 |
Please check your camera settings.
134 |
135 | ) : (
136 |
`capture-${Date.now()}.jpeg`}
146 | onError={(err) => {
147 | console.error("Camera error:", err);
148 | setCameraError(true);
149 | }}
150 | />
151 | )}
152 |
153 | {/* Capture Controls */}
154 | {!cameraError && (
155 |
156 |
162 |
163 |
164 |
172 |
177 |
178 |
179 | )}
180 |
181 |
182 | {/* Gallery Section */}
183 |
184 | {/* Header */}
185 |
186 |
187 |
188 |
Gallery
189 |
190 | {images.length} {images.length === 1 ? "image" : "images"}
191 |
192 |
193 |
194 |
195 | {/* Scrollable grid */}
196 |
197 | {images.length === 0 ? (
198 |
199 |
200 |
201 | No images captured yet
202 |
203 |
204 | ) : (
205 |
206 | {images.map((image, index) => (
207 |
208 |
209 |
214 |
215 |
deleteImage(index)}
217 | aria-label={`Delete image ${index + 1}`}
218 | className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-lg cursor-pointer"
219 | >
220 |
221 |
222 |
223 | ))}
224 |
225 | )}
226 |
227 |
228 | {/* footer buttons */}
229 |
230 |
236 | Cancel
237 |
238 |
243 | {isSaving ? (
244 | <>
245 |
246 | Saving...
247 | >
248 | ) : (
249 | <>
250 | Save
251 | {images.length > 0 && `(${images.length})`}
252 | >
253 | )}
254 |
255 |
256 |
257 |
258 | {/* Loading Overlay */}
259 | {isSaving && (
260 |
261 |
262 |
263 |
264 | Saving images...
265 |
266 |
267 |
268 | )}
269 |
270 |
271 |
272 | );
273 | }
274 |
275 | export default ImageCaptureDialogDesktop;
276 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/components/image-capture-dialog-desktop/image-capture-dialog-desktop.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Camera,
5 | CameraOff,
6 | ImageIcon,
7 | Loader2,
8 | RefreshCcw,
9 | X,
10 | } from "lucide-react";
11 | import { useRef, useState } from "react";
12 | import WebCamera from "@shivantra/react-web-camera";
13 | import type { FacingMode, WebCameraHandler } from "@shivantra/react-web-camera";
14 |
15 | import { Button, Dialog, DialogContent, DialogTitle } from "@/ui/components";
16 |
17 | interface Image {
18 | url: string;
19 | file: File;
20 | }
21 |
22 | export function ImageCaptureDialogDesktop({
23 | open,
24 | onOpenChange,
25 | }: {
26 | open: boolean;
27 | onOpenChange: () => void;
28 | }) {
29 | const [images, setImages] = useState([]);
30 | const [isSaving, setIsSaving] = useState(false);
31 | const [facingMode, setFacingMode] = useState("environment");
32 | const [cameraError, setCameraError] = useState(false);
33 |
34 | const cameraRef = useRef(null);
35 |
36 | /**
37 | * Removes an image from the gallery based on its index.
38 | * @param index The index of the image to be deleted.
39 | */
40 |
41 | const deleteImage = (index: number) => {
42 | setImages((prev) => prev.filter((_, i) => i !== index));
43 | };
44 |
45 | /**
46 | * Handles the save operation. It simulates an asynchronous upload process.
47 | * In a real application, this is where you would perform an API call.
48 | */
49 | const handleSave = async () => {
50 | if (images.length === 0) return;
51 | setIsSaving(true);
52 | try {
53 | const files = images.map((image) => image.file);
54 |
55 | // This Promise simulates a network request, like an API call to upload the files.
56 | // Replace this with your actual save logic (e.g., using fetch or axios).
57 | await new Promise((resolve) => {
58 | setTimeout(() => {
59 | console.log("Saved files:", files);
60 | resolve();
61 | }, 3000);
62 | });
63 |
64 | setImages([]);
65 | onOpenChange?.();
66 | } catch (error) {
67 | console.error("Failed to save images:", error);
68 | // You could add an error state here to show an alert to the user.
69 | } finally {
70 | setIsSaving(false);
71 | }
72 | };
73 |
74 | /**
75 | * Handles the dialog close action. It prompts the user for confirmation
76 | * if there are unsaved images to prevent data loss.
77 | */
78 | const handleClose = () => {
79 | if (images.length > 0 && !isSaving) {
80 | if (
81 | !window.confirm(
82 | "You have unsaved images. Are you sure you want to close?"
83 | )
84 | ) {
85 | return;
86 | }
87 | }
88 | setImages([]);
89 | onOpenChange?.();
90 | };
91 |
92 | /**
93 | * Captures an image from the webcam and adds it to the gallery.
94 | */
95 | const handleCapture = async () => {
96 | if (!cameraRef.current) return;
97 | try {
98 | const file = await cameraRef.current.capture();
99 | if (file) {
100 | const url = URL.createObjectURL(file);
101 | setImages((prev) => [...prev, { url, file }]);
102 | }
103 | } catch (error) {
104 | console.error("Capture error:", error);
105 | }
106 | };
107 |
108 | /**
109 | * Switches the camera between front-facing ('user') and back-facing ('environment').
110 | */
111 | const handleCameraSwitch = async () => {
112 | if (!cameraRef.current) return;
113 | try {
114 | const newMode = facingMode === "user" ? "environment" : "user";
115 | await cameraRef.current.switch(newMode);
116 | setFacingMode(newMode);
117 | } catch (error) {
118 | console.error("Camera switch error:", error);
119 | }
120 | };
121 |
122 | return (
123 |
124 |
125 |
126 |
127 | {/* Camera Section */}
128 |
129 | {cameraError ? (
130 |
131 |
132 |
Camera not available or permission denied.
133 |
Please check your camera settings.
134 |
135 | ) : (
136 |
`capture-${Date.now()}.jpeg`}
146 | onError={(err) => {
147 | console.error("Camera error:", err);
148 | setCameraError(true);
149 | }}
150 | />
151 | )}
152 |
153 | {/* Capture Controls */}
154 | {!cameraError && (
155 |
156 |
162 |
163 |
164 |
172 |
177 |
178 |
179 | )}
180 |
181 |
182 | {/* Gallery Section */}
183 |
184 | {/* Header */}
185 |
186 |
187 |
188 |
Gallery
189 |
190 | {images.length} {images.length === 1 ? "image" : "images"}
191 |
192 |
193 |
194 |
195 | {/* Scrollable grid */}
196 |
197 | {images.length === 0 ? (
198 |
199 |
200 |
201 | No images captured yet
202 |
203 |
204 | ) : (
205 |
206 | {images.map((image, index) => (
207 |
208 |
209 |
214 |
215 |
deleteImage(index)}
217 | aria-label={`Delete image ${index + 1}`}
218 | className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-lg cursor-pointer"
219 | >
220 |
221 |
222 |
223 | ))}
224 |
225 | )}
226 |
227 |
228 | {/* footer buttons */}
229 |
230 |
236 | Cancel
237 |
238 |
243 | {isSaving ? (
244 | <>
245 |
246 | Saving...
247 | >
248 | ) : (
249 | <>
250 | Save
251 | {images.length > 0 && `(${images.length})`}
252 | >
253 | )}
254 |
255 |
256 |
257 |
258 | {/* Loading Overlay */}
259 | {isSaving && (
260 |
261 |
262 |
263 |
264 | Saving images...
265 |
266 |
267 |
268 | )}
269 |
270 |
271 |
272 | );
273 | }
274 |
275 | export default ImageCaptureDialogDesktop;
276 |
--------------------------------------------------------------------------------
/examples/with-vite/src/views/ImageCaptureDialogMobile.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Dialog, DialogContent, DialogTitle } from "@/ui/components";
2 | import { Camera, CameraOff, Loader2, RefreshCcw, Save, X } from "lucide-react";
3 | import { useRef, useState } from "react";
4 | import WebCamera from "@shivantra/react-web-camera";
5 | import type { FacingMode, WebCameraHandler } from "@shivantra/react-web-camera";
6 |
7 | interface Image {
8 | url: string;
9 | file: File;
10 | }
11 |
12 | export function ImageCaptureDialogMobile({
13 | open,
14 | onOpenChange,
15 | }: {
16 | open: boolean;
17 | onOpenChange: () => void;
18 | }) {
19 | const [images, setImages] = useState([]);
20 | const [facingMode, setFacingMode] = useState("environment");
21 | const [isSaving, setIsSaving] = useState(false);
22 | const [showGallery, setShowGallery] = useState(false);
23 | const [cameraError, setCameraError] = useState(false);
24 |
25 | const cameraRef = useRef(null);
26 |
27 | /**
28 | * Removes an image from the gallery based on its index.
29 | * @param index The index of the image to be deleted.
30 | */
31 | const deleteImage = (index: number) => {
32 | setImages((prev) => prev.filter((_, i) => i !== index));
33 | };
34 |
35 | /**
36 | * Handles the save operation. It simulates an asynchronous upload process.
37 | * In a real application, this is where you would perform an API call.
38 | */
39 | const handleSave = async () => {
40 | if (images.length === 0) return;
41 | setIsSaving(true);
42 | try {
43 | const files = images.map((image) => image.file);
44 |
45 | // This Promise simulates a network request, like an API call to upload the files.
46 | // Replace this with your actual save logic (e.g., using fetch or axios).
47 | await new Promise((resolve) => {
48 | setTimeout(() => {
49 | console.log("Saved files:", files);
50 | resolve();
51 | }, 3000);
52 | });
53 |
54 | setImages([]);
55 | onOpenChange?.();
56 | } catch (error) {
57 | console.error("Failed to save images:", error);
58 | // You could add an error state here to show an alert to the user.
59 | } finally {
60 | setIsSaving(false);
61 | }
62 | };
63 |
64 | /**
65 | * Handles the dialog close action. It prompts the user for confirmation
66 | * if there are unsaved images to prevent data loss.
67 | */
68 | const handleClose = () => {
69 | if (images.length > 0 && !isSaving) {
70 | if (
71 | !window.confirm(
72 | "You have unsaved images. Are you sure you want to close?"
73 | )
74 | ) {
75 | return;
76 | }
77 | }
78 | setImages([]);
79 | onOpenChange?.();
80 | };
81 |
82 | /**
83 | * Captures an image from the webcam and adds it to the gallery.
84 | */
85 | const handleCapture = async () => {
86 | if (!cameraRef.current) return;
87 | try {
88 | const file = await cameraRef.current.capture();
89 | if (file) {
90 | const url = URL.createObjectURL(file);
91 | setImages((prev) => [...prev, { url, file }]);
92 | }
93 | } catch (error) {
94 | console.error("Capture error:", error);
95 | }
96 | };
97 |
98 | /**
99 | * Switches the camera between front-facing ('user') and back-facing ('environment').
100 | */
101 | const handleCameraSwitch = async () => {
102 | if (!cameraRef.current) return;
103 | try {
104 | const newMode = facingMode === "user" ? "environment" : "user";
105 | await cameraRef.current.switch(newMode);
106 | setFacingMode(newMode);
107 | } catch (error) {
108 | console.error("Camera switch error:", error);
109 | }
110 | };
111 |
112 | return (
113 |
114 |
115 |
116 |
117 | {/* Camera View */}
118 |
119 | {cameraError ? (
120 |
121 |
122 |
Camera not available or permission denied.
123 |
Please check your camera settings.
124 |
125 | ) : (
126 |
`capture-${Date.now()}.jpeg`}
136 | onError={(err) => {
137 | console.error("Camera error:", err);
138 | setCameraError(true);
139 | }}
140 | />
141 | )}
142 |
143 |
144 |
145 |
146 | {images.length > 0 ? (
147 |
setShowGallery(true)}
149 | className="relative w-full h-full rounded-2xl overflow-hidden border-2 border-white/30 bg-black/50 backdrop-blur-sm"
150 | >
151 |
158 | {images.length > 1 && (
159 |
160 |
161 | +{images.length - 1}
162 |
163 |
164 | )}
165 |
166 | ) : (
167 |
168 | )}
169 |
170 |
171 |
176 |
177 |
178 |
179 |
186 |
191 |
192 |
193 |
194 |
195 |
196 | {/* Bottom Actions */}
197 |
198 |
199 |
205 |
206 | Cancel
207 |
208 |
214 | {isSaving ? (
215 | <>
216 |
217 | Saving...
218 | >
219 | ) : (
220 | <>
221 |
222 | Save {images.length > 0 && `(${images.length})`}
223 | >
224 | )}
225 |
226 |
227 |
228 | {/* Gallery Modal */}
229 | {showGallery && (
230 |
231 | {/* Gallery Header */}
232 |
233 |
234 | {images.length} Photo{images.length !== 1 ? "s" : ""}
235 |
236 | setShowGallery(false)}
240 | className="text-white hover:bg-white/20 rounded-full"
241 | >
242 |
243 |
244 |
245 |
246 | {/* Gallery Grid */}
247 |
248 |
249 | {images.map((image, index) => (
250 |
251 |
252 |
257 |
258 |
deleteImage(index)}
260 | className="absolute top-1 right-1 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg p-0"
261 | >
262 |
263 |
264 |
265 | {index + 1}
266 |
267 |
268 | ))}
269 |
270 |
271 |
272 | {/* Gallery Footer */}
273 |
274 |
275 | setImages([])}
278 | disabled={images.length === 0}
279 | className="flex-1 bg-red-500/20 border-red-500/30 text-white hover:bg-red-500/30 hover:text-white"
280 | >
281 | Clear All
282 |
283 | {
285 | setShowGallery(false);
286 | handleSave();
287 | }}
288 | disabled={images.length === 0}
289 | className="flex-1 bg-primary hover:bg-primary text-white"
290 | >
291 | Save All
292 |
293 |
294 |
295 |
296 | )}
297 |
298 |
299 |
300 | );
301 | }
302 |
303 | export default ImageCaptureDialogMobile;
304 |
--------------------------------------------------------------------------------
/www/app/components/image-capture-dialog-mobile/image-capture-dialog-mobile.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Camera, CameraOff, Loader2, RefreshCcw, Save, X } from "lucide-react";
4 | import { useRef, useState } from "react";
5 | import WebCamera from "@shivantra/react-web-camera";
6 | import type { FacingMode, WebCameraHandler } from "@shivantra/react-web-camera";
7 |
8 | import { Button, Dialog, DialogContent, DialogTitle } from "@/ui/components";
9 |
10 | interface Image {
11 | url: string;
12 | file: File;
13 | }
14 |
15 | export function ImageCaptureDialogMobile({
16 | open,
17 | onOpenChange,
18 | }: {
19 | open: boolean;
20 | onOpenChange: () => void;
21 | }) {
22 | const [images, setImages] = useState([]);
23 | const [facingMode, setFacingMode] = useState("environment");
24 | const [isSaving, setIsSaving] = useState(false);
25 | const [showGallery, setShowGallery] = useState(false);
26 | const [cameraError, setCameraError] = useState(false);
27 |
28 | const cameraRef = useRef(null);
29 |
30 | /**
31 | * Removes an image from the gallery based on its index.
32 | * @param index The index of the image to be deleted.
33 | */
34 | const deleteImage = (index: number) => {
35 | setImages((prev) => prev.filter((_, i) => i !== index));
36 | };
37 |
38 | /**
39 | * Handles the save operation. It simulates an asynchronous upload process.
40 | * In a real application, this is where you would perform an API call.
41 | */
42 | const handleSave = async () => {
43 | if (images.length === 0) return;
44 | setIsSaving(true);
45 | try {
46 | const files = images.map((image) => image.file);
47 |
48 | // This Promise simulates a network request, like an API call to upload the files.
49 | // Replace this with your actual save logic (e.g., using fetch or axios).
50 | await new Promise((resolve) => {
51 | setTimeout(() => {
52 | console.log("Saved files:", files);
53 | resolve();
54 | }, 3000);
55 | });
56 |
57 | setImages([]);
58 | onOpenChange?.();
59 | } catch (error) {
60 | console.error("Failed to save images:", error);
61 | // You could add an error state here to show an alert to the user.
62 | } finally {
63 | setIsSaving(false);
64 | }
65 | };
66 |
67 | /**
68 | * Handles the dialog close action. It prompts the user for confirmation
69 | * if there are unsaved images to prevent data loss.
70 | */
71 | const handleClose = () => {
72 | if (images.length > 0 && !isSaving) {
73 | if (
74 | !window.confirm(
75 | "You have unsaved images. Are you sure you want to close?"
76 | )
77 | ) {
78 | return;
79 | }
80 | }
81 | setImages([]);
82 | onOpenChange?.();
83 | };
84 |
85 | /**
86 | * Captures an image from the webcam and adds it to the gallery.
87 | */
88 | const handleCapture = async () => {
89 | if (!cameraRef.current) return;
90 | try {
91 | const file = await cameraRef.current.capture();
92 | if (file) {
93 | const url = URL.createObjectURL(file);
94 | setImages((prev) => [...prev, { url, file }]);
95 | }
96 | } catch (error) {
97 | console.error("Capture error:", error);
98 | }
99 | };
100 |
101 | /**
102 | * Switches the camera between front-facing ('user') and back-facing ('environment').
103 | */
104 | const handleCameraSwitch = async () => {
105 | if (!cameraRef.current) return;
106 | try {
107 | const newMode = facingMode === "user" ? "environment" : "user";
108 | await cameraRef.current.switch(newMode);
109 | setFacingMode(newMode);
110 | } catch (error) {
111 | console.error("Camera switch error:", error);
112 | }
113 | };
114 |
115 | return (
116 |
117 |
118 |
119 |
120 | {/* Camera View */}
121 |
122 | {cameraError ? (
123 |
124 |
125 |
Camera not available or permission denied.
126 |
Please check your camera settings.
127 |
128 | ) : (
129 |
`capture-${Date.now()}.jpeg`}
139 | onError={(err) => {
140 | console.error("Camera error:", err);
141 | setCameraError(true);
142 | }}
143 | />
144 | )}
145 |
146 |
147 |
148 |
149 | {images.length > 0 ? (
150 |
setShowGallery(true)}
152 | className="relative w-full h-full rounded-2xl overflow-hidden border-2 border-white/30 bg-black/50 backdrop-blur-sm"
153 | >
154 |
161 | {images.length > 1 && (
162 |
163 |
164 | +{images.length - 1}
165 |
166 |
167 | )}
168 |
169 | ) : (
170 |
171 | )}
172 |
173 |
174 |
179 |
180 |
181 |
182 |
189 |
194 |
195 |
196 |
197 |
198 |
199 | {/* Bottom Actions */}
200 |
201 |
202 |
208 |
209 | Cancel
210 |
211 |
217 | {isSaving ? (
218 | <>
219 |
220 | Saving...
221 | >
222 | ) : (
223 | <>
224 |
225 | Save {images.length > 0 && `(${images.length})`}
226 | >
227 | )}
228 |
229 |
230 |
231 | {/* Gallery Modal */}
232 | {showGallery && (
233 |
234 | {/* Gallery Header */}
235 |
236 |
237 | {images.length} Photo{images.length !== 1 ? "s" : ""}
238 |
239 | setShowGallery(false)}
243 | className="text-white hover:bg-white/20 rounded-full cursor-pointer"
244 | >
245 |
246 |
247 |
248 |
249 | {/* Gallery Grid */}
250 |
251 |
252 | {images.map((image, index) => (
253 |
254 |
255 |
260 |
261 |
deleteImage(index)}
263 | className="absolute top-1 right-1 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg p-0 cursor-pointer"
264 | >
265 |
266 |
267 |
268 | {index + 1}
269 |
270 |
271 | ))}
272 |
273 |
274 |
275 | {/* Gallery Footer */}
276 |
277 |
278 | setImages([])}
281 | disabled={images.length === 0}
282 | className="flex-1 bg-red-500/20 border-red-500/30 text-white hover:bg-red-500/30 hover:text-white"
283 | >
284 | Clear All
285 |
286 | {
288 | setShowGallery(false);
289 | handleSave();
290 | }}
291 | disabled={images.length === 0}
292 | className="flex-1 bg-primary hover:bg-primary text-white"
293 | >
294 | Save All
295 |
296 |
297 |
298 |
299 | )}
300 |
301 |
302 |
303 | );
304 | }
305 |
306 | export default ImageCaptureDialogMobile;
307 |
--------------------------------------------------------------------------------
/examples/with-nextjs/app/components/image-capture-dialog-mobile/image-capture-dialog-mobile.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Camera, CameraOff, Loader2, RefreshCcw, Save, X } from "lucide-react";
4 | import { useRef, useState } from "react";
5 | import WebCamera from "@shivantra/react-web-camera";
6 | import type { FacingMode, WebCameraHandler } from "@shivantra/react-web-camera";
7 |
8 | import { Button, Dialog, DialogContent, DialogTitle } from "@/ui/components";
9 |
10 | interface Image {
11 | url: string;
12 | file: File;
13 | }
14 |
15 | export function ImageCaptureDialogMobile({
16 | open,
17 | onOpenChange,
18 | }: {
19 | open: boolean;
20 | onOpenChange: () => void;
21 | }) {
22 | const [images, setImages] = useState([]);
23 | const [facingMode, setFacingMode] = useState("environment");
24 | const [isSaving, setIsSaving] = useState(false);
25 | const [showGallery, setShowGallery] = useState(false);
26 | const [cameraError, setCameraError] = useState(false);
27 |
28 | const cameraRef = useRef(null);
29 |
30 | /**
31 | * Removes an image from the gallery based on its index.
32 | * @param index The index of the image to be deleted.
33 | */
34 | const deleteImage = (index: number) => {
35 | setImages((prev) => prev.filter((_, i) => i !== index));
36 | };
37 |
38 | /**
39 | * Handles the save operation. It simulates an asynchronous upload process.
40 | * In a real application, this is where you would perform an API call.
41 | */
42 | const handleSave = async () => {
43 | if (images.length === 0) return;
44 | setIsSaving(true);
45 | try {
46 | const files = images.map((image) => image.file);
47 |
48 | // This Promise simulates a network request, like an API call to upload the files.
49 | // Replace this with your actual save logic (e.g., using fetch or axios).
50 | await new Promise((resolve) => {
51 | setTimeout(() => {
52 | console.log("Saved files:", files);
53 | resolve();
54 | }, 3000);
55 | });
56 |
57 | setImages([]);
58 | onOpenChange?.();
59 | } catch (error) {
60 | console.error("Failed to save images:", error);
61 | // You could add an error state here to show an alert to the user.
62 | } finally {
63 | setIsSaving(false);
64 | }
65 | };
66 |
67 | /**
68 | * Handles the dialog close action. It prompts the user for confirmation
69 | * if there are unsaved images to prevent data loss.
70 | */
71 | const handleClose = () => {
72 | if (images.length > 0 && !isSaving) {
73 | if (
74 | !window.confirm(
75 | "You have unsaved images. Are you sure you want to close?"
76 | )
77 | ) {
78 | return;
79 | }
80 | }
81 | setImages([]);
82 | onOpenChange?.();
83 | };
84 |
85 | /**
86 | * Captures an image from the webcam and adds it to the gallery.
87 | */
88 | const handleCapture = async () => {
89 | if (!cameraRef.current) return;
90 | try {
91 | const file = await cameraRef.current.capture();
92 | if (file) {
93 | const url = URL.createObjectURL(file);
94 | setImages((prev) => [...prev, { url, file }]);
95 | }
96 | } catch (error) {
97 | console.error("Capture error:", error);
98 | }
99 | };
100 |
101 | /**
102 | * Switches the camera between front-facing ('user') and back-facing ('environment').
103 | */
104 | const handleCameraSwitch = async () => {
105 | if (!cameraRef.current) return;
106 | try {
107 | const newMode = facingMode === "user" ? "environment" : "user";
108 | await cameraRef.current.switch(newMode);
109 | setFacingMode(newMode);
110 | } catch (error) {
111 | console.error("Camera switch error:", error);
112 | }
113 | };
114 |
115 | return (
116 |
117 |
118 |
119 |
120 | {/* Camera View */}
121 |
122 | {cameraError ? (
123 |
124 |
125 |
Camera not available or permission denied.
126 |
Please check your camera settings.
127 |
128 | ) : (
129 |
`capture-${Date.now()}.jpeg`}
139 | onError={(err) => {
140 | console.error("Camera error:", err);
141 | setCameraError(true);
142 | }}
143 | />
144 | )}
145 |
146 |
147 |
148 |
149 | {images.length > 0 ? (
150 |
setShowGallery(true)}
152 | className="relative w-full h-full rounded-2xl overflow-hidden border-2 border-white/30 bg-black/50 backdrop-blur-sm"
153 | >
154 |
161 | {images.length > 1 && (
162 |
163 |
164 | +{images.length - 1}
165 |
166 |
167 | )}
168 |
169 | ) : (
170 |
171 | )}
172 |
173 |
174 |
179 |
180 |
181 |
182 |
189 |
194 |
195 |
196 |
197 |
198 |
199 | {/* Bottom Actions */}
200 |
201 |
202 |
208 |
209 | Cancel
210 |
211 |
217 | {isSaving ? (
218 | <>
219 |
220 | Saving...
221 | >
222 | ) : (
223 | <>
224 |
225 | Save {images.length > 0 && `(${images.length})`}
226 | >
227 | )}
228 |
229 |
230 |
231 | {/* Gallery Modal */}
232 | {showGallery && (
233 |
234 | {/* Gallery Header */}
235 |
236 |
237 | {images.length} Photo{images.length !== 1 ? "s" : ""}
238 |
239 | setShowGallery(false)}
243 | className="text-white hover:bg-white/20 rounded-full cursor-pointer"
244 | >
245 |
246 |
247 |
248 |
249 | {/* Gallery Grid */}
250 |
251 |
252 | {images.map((image, index) => (
253 |
254 |
255 |
260 |
261 |
deleteImage(index)}
263 | className="absolute top-1 right-1 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg p-0 cursor-pointer"
264 | >
265 |
266 |
267 |
268 | {index + 1}
269 |
270 |
271 | ))}
272 |
273 |
274 |
275 | {/* Gallery Footer */}
276 |
277 |
278 | setImages([])}
281 | disabled={images.length === 0}
282 | className="flex-1 bg-red-500/20 border-red-500/30 text-white hover:bg-red-500/30 hover:text-white"
283 | >
284 | Clear All
285 |
286 | {
288 | setShowGallery(false);
289 | handleSave();
290 | }}
291 | disabled={images.length === 0}
292 | className="flex-1 bg-primary hover:bg-primary text-white"
293 | >
294 | Save All
295 |
296 |
297 |
298 |
299 | )}
300 |
301 |
302 |
303 | );
304 | }
305 |
306 | export default ImageCaptureDialogMobile;
307 |
--------------------------------------------------------------------------------
/www/public/workbox-8ad7dfbc.js:
--------------------------------------------------------------------------------
1 | define(["exports"],function(t){"use strict";try{self["workbox:core:7.0.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.0.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:7.0.0"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:r,cachedResponse:s,request:i,event:this.event})||void 0;return s}async cachePut(t,e){const n=m(t);var r;await(r=0,new Promise(t=>setTimeout(t,r)));const i=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(a=i.url,new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var a;const o=await this.R(e);if(!o)return!1;const{cacheName:c,matchOptions:h}=this.u,u=await self.caches.open(c),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const r=p(e.url,s);if(e.url===r)return t.match(e,n);const i=Object.assign(Object.assign({},n),{ignoreSearch:!0}),a=await t.keys(e,i);for(const e of a)if(r===p(e.url,s))return t.match(e,n)}(u,i.clone(),["__WB_REVISION__"],h):null;try{await u.put(i,l?o.clone():o)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of g)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:c,oldResponse:f,newResponse:o.clone(),request:i,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.h[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await t({mode:e,request:n,event:this.event,params:this.params}));this.h[s]=n}return this.h[s]}hasCallback(t){for(const e of this.u.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.u.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const r=Object.assign(Object.assign({},n),{state:s});return e[t](r)};yield n}}waitUntil(t){return this.p.push(t),t}async doneWaiting(){let t;for(;t=this.p.shift();)await t}destroy(){this.l.resolve(null)}async R(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class R{constructor(t={}){this.cacheName=d(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,r=new v(this,{event:e,request:s,params:n}),i=this.q(r,s,e);return[i,this.D(i,r,s,e)]}async q(t,e,n){let r;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(r=await this.U(e,t),!r||"error"===r.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const i of t.iterateCallbacks("handlerDidError"))if(r=await i({error:s,event:n,request:e}),r)break;if(!r)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))r=await s({event:n,request:e,response:r});return r}async D(t,e,s,n){let r,i;try{r=await t}catch(i){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await e.doneWaiting()}catch(t){t instanceof Error&&(i=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:i}),e.destroy(),i)throw i}}function b(t){t.then(()=>{})}function q(){return q=Object.assign?Object.assign.bind():function(t){for(var e=1;e(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function O(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(U||(U=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(B(this),e),k(x.get(this))}:function(...e){return k(t.apply(B(this),e))}:function(e,...s){const n=t.call(B(this),e,...s);return I.set(n,e.sort?e.sort():[e]),k(n)}}function T(t){return"function"==typeof t?O(t):(t instanceof IDBTransaction&&function(t){if(L.has(t))return;const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("complete",r),t.removeEventListener("error",i),t.removeEventListener("abort",i)},r=()=>{e(),n()},i=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",r),t.addEventListener("error",i),t.addEventListener("abort",i)});L.set(t,e)}(t),e=t,(D||(D=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some(t=>e instanceof t)?new Proxy(t,N):t);var e}function k(t){if(t instanceof IDBRequest)return function(t){const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("success",r),t.removeEventListener("error",i)},r=()=>{e(k(t.result)),n()},i=()=>{s(t.error),n()};t.addEventListener("success",r),t.addEventListener("error",i)});return e.then(e=>{e instanceof IDBCursor&&x.set(e,t)}).catch(()=>{}),E.set(e,t),e}(t);if(C.has(t))return C.get(t);const e=T(t);return e!==t&&(C.set(t,e),E.set(e,t)),e}const B=t=>E.get(t);const P=["get","getKey","getAll","getAllKeys","count"],M=["put","add","delete","clear"],W=new Map;function j(t,e){if(!(t instanceof IDBDatabase)||e in t||"string"!=typeof e)return;if(W.get(e))return W.get(e);const s=e.replace(/FromIndex$/,""),n=e!==s,r=M.includes(s);if(!(s in(n?IDBIndex:IDBObjectStore).prototype)||!r&&!P.includes(s))return;const i=async function(t,...e){const i=this.transaction(t,r?"readwrite":"readonly");let a=i.store;return n&&(a=a.index(e.shift())),(await Promise.all([a[s](...e),r&&i.done]))[0]};return W.set(e,i),i}N=(t=>q({},t,{get:(e,s,n)=>j(e,s)||t.get(e,s,n),has:(e,s)=>!!j(e,s)||t.has(e,s)}))(N);try{self["workbox:expiration:7.0.0"]&&_()}catch(t){}const S="cache-entries",K=t=>{const e=new URL(t,location.href);return e.hash="",e.href};class A{constructor(t){this._=null,this.L=t}I(t){const e=t.createObjectStore(S,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1})}C(t){this.I(t),this.L&&function(t,{blocked:e}={}){const s=indexedDB.deleteDatabase(t);e&&s.addEventListener("blocked",t=>e(t.oldVersion,t)),k(s).then(()=>{})}(this.L)}async setTimestamp(t,e){const s={url:t=K(t),timestamp:e,cacheName:this.L,id:this.N(t)},n=(await this.getDb()).transaction(S,"readwrite",{durability:"relaxed"});await n.store.put(s),await n.done}async getTimestamp(t){const e=await this.getDb(),s=await e.get(S,this.N(t));return null==s?void 0:s.timestamp}async expireEntries(t,e){const s=await this.getDb();let n=await s.transaction(S).store.index("timestamp").openCursor(null,"prev");const r=[];let i=0;for(;n;){const s=n.value;s.cacheName===this.L&&(t&&s.timestamp=e?r.push(n.value):i++),n=await n.continue()}const a=[];for(const t of r)await s.delete(S,t.id),a.push(t.url);return a}N(t){return this.L+"|"+K(t)}async getDb(){return this._||(this._=await function(t,e,{blocked:s,upgrade:n,blocking:r,terminated:i}={}){const a=indexedDB.open(t,e),o=k(a);return n&&a.addEventListener("upgradeneeded",t=>{n(k(a.result),t.oldVersion,t.newVersion,k(a.transaction),t)}),s&&a.addEventListener("blocked",t=>s(t.oldVersion,t.newVersion,t)),o.then(t=>{i&&t.addEventListener("close",()=>i()),r&&t.addEventListener("versionchange",t=>r(t.oldVersion,t.newVersion,t))}).catch(()=>{}),o}("workbox-expiration",1,{upgrade:this.C.bind(this)})),this._}}class F{constructor(t,e={}){this.O=!1,this.T=!1,this.k=e.maxEntries,this.B=e.maxAgeSeconds,this.P=e.matchOptions,this.L=t,this.M=new A(t)}async expireEntries(){if(this.O)return void(this.T=!0);this.O=!0;const t=this.B?Date.now()-1e3*this.B:0,e=await this.M.expireEntries(t,this.k),s=await self.caches.open(this.L);for(const t of e)await s.delete(t,this.P);this.O=!1,this.T&&(this.T=!1,b(this.expireEntries()))}async updateTimestamp(t){await this.M.setTimestamp(t,Date.now())}async isURLExpired(t){if(this.B){const e=await this.M.getTimestamp(t),s=Date.now()-1e3*this.B;return void 0===e||er||e&&e<0)throw new s("range-not-satisfiable",{size:r,end:n,start:e});let i,a;return void 0!==e&&void 0!==n?(i=e,a=n+1):void 0!==e&&void 0===n?(i=e,a=r):void 0!==n&&void 0===e&&(i=r-n,a=r),{start:i,end:a}}(i,r.start,r.end),o=i.slice(a.start,a.end),c=o.size,h=new Response(o,{status:206,statusText:"Partial Content",headers:e.headers});return h.headers.set("Content-Length",String(c)),h.headers.set("Content-Range",`bytes ${a.start}-${a.end-1}/${i.size}`),h}catch(t){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}function $(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.0.0"]&&_()}catch(t){}function z(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const r=new URL(n,location.href),i=new URL(n,location.href);return r.searchParams.set("__WB_REVISION__",e),{cacheKey:r.href,url:i.href}}class G{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class V{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.W.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.W=t}}let J,Q;async function X(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const r=t.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},a=e?e(i):i,o=function(){if(void 0===J){const t=new Response("");if("body"in t)try{new Response(t.body),J=!0}catch(t){J=!1}J=!1}return J}()?r.body:await r.blob();return new Response(o,a)}class Y extends R{constructor(t={}){t.cacheName=w(t.cacheName),super(t),this.j=!1!==t.fallbackToNetwork,this.plugins.push(Y.copyRedirectedCacheableResponsesPlugin)}async U(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.S(t,e):await this.K(t,e))}async K(t,e){let n;const r=e.params||{};if(!this.j)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=r.integrity,i=t.integrity,a=!i||i===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?i||s:void 0})),s&&a&&"no-cors"!==t.mode&&(this.A(),await e.cachePut(t,n.clone()))}return n}async S(t,e){this.A();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}A(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==Y.copyRedirectedCacheableResponsesPlugin&&(n===Y.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(Y.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}Y.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},Y.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await X(t):t};class Z{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.F=new Map,this.H=new Map,this.$=new Map,this.u=new Y({cacheName:w(t),plugins:[...e,new V({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.u}precache(t){this.addToCacheList(t),this.G||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.G=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:r}=z(n),i="string"!=typeof n&&n.revision?"reload":"default";if(this.F.has(r)&&this.F.get(r)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.F.get(r),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.$.has(t)&&this.$.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:r});this.$.set(t,n.integrity)}if(this.F.set(r,t),this.H.set(r,i),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return $(t,async()=>{const e=new G;this.strategy.plugins.push(e);for(const[e,s]of this.F){const n=this.$.get(s),r=this.H.get(e),i=new Request(e,{integrity:n,cache:r,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:i,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return $(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.F.values()),n=[];for(const r of e)s.has(r.url)||(await t.delete(r),n.push(r.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.F}getCachedURLs(){return[...this.F.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.F.get(e.href)}getIntegrityForCacheKey(t){return this.$.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}const tt=()=>(Q||(Q=new Z),Q);class et extends r{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const r of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:r}={}){const i=new URL(t,location.href);i.hash="",yield i.href;const a=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(i,e);if(yield a.href,s&&a.pathname.endsWith("/")){const t=new URL(a.href);t.pathname+=s,yield t.href}if(n){const t=new URL(a.href);t.pathname+=".html",yield t.href}if(r){const t=r({url:i});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(r);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}t.CacheFirst=class extends R{async U(t,e){let n,r=await e.cacheMatch(t);if(!r)try{r=await e.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!r)throw new s("no-response",{url:t.url,error:n});return r}},t.ExpirationPlugin=class{constructor(t={}){this.cachedResponseWillBeUsed=async({event:t,request:e,cacheName:s,cachedResponse:n})=>{if(!n)return null;const r=this.V(n),i=this.J(s);b(i.expireEntries());const a=i.updateTimestamp(e.url);if(t)try{t.waitUntil(a)}catch(t){}return r?n:null},this.cacheDidUpdate=async({cacheName:t,request:e})=>{const s=this.J(t);await s.updateTimestamp(e.url),await s.expireEntries()},this.X=t,this.B=t.maxAgeSeconds,this.Y=new Map,t.purgeOnQuotaError&&function(t){g.add(t)}(()=>this.deleteCacheAndMetadata())}J(t){if(t===d())throw new s("expire-custom-caches-only");let e=this.Y.get(t);return e||(e=new F(t,this.X),this.Y.set(t,e)),e}V(t){if(!this.B)return!0;const e=this.Z(t);if(null===e)return!0;return e>=Date.now()-1e3*this.B}Z(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async deleteCacheAndMetadata(){for(const[t,e]of this.Y)await self.caches.delete(t),await e.delete();this.Y=new Map}},t.NetworkFirst=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u),this.tt=t.networkTimeoutSeconds||0}async U(t,e){const n=[],r=[];let i;if(this.tt){const{id:s,promise:a}=this.et({request:t,logs:n,handler:e});i=s,r.push(a)}const a=this.st({timeoutId:i,request:t,logs:n,handler:e});r.push(a);const o=await e.waitUntil((async()=>await e.waitUntil(Promise.race(r))||await a)());if(!o)throw new s("no-response",{url:t.url});return o}et({request:t,logs:e,handler:s}){let n;return{promise:new Promise(e=>{n=setTimeout(async()=>{e(await s.cacheMatch(t))},1e3*this.tt)}),id:n}}async st({timeoutId:t,request:e,logs:s,handler:n}){let r,i;try{i=await n.fetchAndCachePut(e)}catch(t){t instanceof Error&&(r=t)}return t&&clearTimeout(t),!r&&i||(i=await n.cacheMatch(e)),i}},t.RangeRequestsPlugin=class{constructor(){this.cachedResponseWillBeUsed=async({request:t,cachedResponse:e})=>e&&t.headers.has("range")?await H(t,e):e}},t.StaleWhileRevalidate=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u)}async U(t,e){const n=e.fetchAndCachePut(t).catch(()=>{});e.waitUntil(n);let r,i=await e.cacheMatch(t);if(i);else try{i=await n}catch(t){t instanceof Error&&(r=t)}if(!i)throw new s("no-response",{url:t.url,error:r});return i}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=w();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.clientsClaim=function(){self.addEventListener("activate",()=>self.clients.claim())},t.precacheAndRoute=function(t,e){!function(t){tt().precache(t)}(t),function(t){const e=tt();h(new et(e,t))}(e)},t.registerRoute=h});
2 |
--------------------------------------------------------------------------------