Pārlūkot izejas kodu

Take inventory photos with the computer's camera at the desk (#556)

* Take inventory photos with the computer's camera at the desk

The inventory part form's Camera button was a file input with capture,
which phones honour and desk browsers ignore, so on a computer it opened
the file picker. On a desk it now opens a camera dialog that takes the
photo with the built-in camera or a plugged-in webcam (#555).

* Turn the camera off on failure and drop a photo made after closing

A failed camera list no longer counts as a camera failure, and any error
after the camera started now stops it. A photo still being encoded when
the dialog closes is thrown away instead of showing on the next open.
Bernt Christian Egeland 1 dienu atpakaļ
vecāks
revīzija
aa3b1b1112

+ 57 - 0
e2e/specs/inventory/camera.spec.ts

@@ -0,0 +1,57 @@
+import { expect, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+
+/**
+ * Photographing a part at the desk. A phone's "Camera" opens its camera app
+ * from a file input; a desk browser ignores that and shows the file picker
+ * (issue #555), so the page takes the photo itself with the computer's camera
+ * or a plugged-in webcam, and the photo joins the part's gallery exactly as an
+ * uploaded file would.
+ *
+ * Chromium is handed a fake camera and the permission is granted up front, so
+ * the camera runs as it would for a person who said yes.
+ */
+
+test.use({
+  launchOptions: {
+    args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
+  },
+  permissions: ['camera'],
+})
+
+test('Camera on a desk computer takes the part photo with the webcam, not the file picker', async ({
+  page,
+}) => {
+  await page.goto('/inventory')
+  await settle(page)
+
+  await expect(async () => {
+    // The toolbar's button; the empty list has a second one.
+    await page.getByRole('button', { name: 'Add Part' }).first().click()
+    await expect(page.getByRole('dialog', { name: 'Add New Part' })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  const form = page.getByRole('dialog', { name: 'Add New Part' })
+
+  // No file picker: the page's own camera.
+  let picked = false
+  page.on('filechooser', () => {
+    picked = true
+  })
+  await form.getByRole('button', { name: 'Camera', exact: true }).click()
+  const camera = page.getByTestId('camera-photo')
+  await expect(camera).toBeVisible()
+  expect(picked, 'a desk computer is not sent to the file picker').toBe(false)
+
+  const take = camera.getByRole('button', { name: 'Take photo' })
+  await expect(take).toBeEnabled({ timeout: 15_000 })
+  await take.click()
+
+  // Looked at before it is used.
+  await expect(camera.getByRole('img', { name: 'The photo you just took' })).toBeVisible()
+  await camera.getByRole('button', { name: 'Use this photo' }).click()
+  await expect(camera).toBeHidden()
+  await expect(page.getByText('Image uploaded')).toBeVisible({ timeout: 30_000 })
+  await expect(form.getByRole('img', { name: 'Part 1' })).toBeVisible()
+})

+ 29 - 0
messages/de/common.json

@@ -153,6 +153,35 @@
     "use": "Dieses Video verwenden",
     "limit": "Die Aufnahme endet automatisch nach {minutes} Minuten."
   },
+  "photoCapture": {
+    "title": "Foto aufnehmen",
+    "description": "Mit der Kamera dieses Computers oder einer angeschlossenen Webcam.",
+    "starting": "Kamera wird gestartet...",
+    "live": "Kameravorschau",
+    "preview": "Das gerade aufgenommene Foto",
+    "camera": "Kamera",
+    "take": "Foto aufnehmen",
+    "retake": "Neu aufnehmen",
+    "use": "Dieses Foto verwenden",
+    "tryAgain": "Erneut versuchen",
+    "uploadInstead": "Stattdessen Datei hochladen",
+    "denied": {
+      "title": "Kamerazugriff ist blockiert",
+      "body": "Erlauben Sie die Kamera für diese Seite in Ihrem Browser, über das Kamera- oder Schlosssymbol in der Adressleiste, und klicken Sie dann auf Erneut versuchen."
+    },
+    "insecure": {
+      "title": "Die Kamera braucht HTTPS",
+      "body": "Browser erlauben die Kamera nur über eine sichere Verbindung, und diese Seite läuft über HTTP ({host}). Öffnen Sie Torqvoice über HTTPS, oder laden Sie stattdessen eine Datei hoch."
+    },
+    "notFound": {
+      "title": "Keine Kamera gefunden",
+      "body": "Schließen Sie eine Webcam an und klicken Sie auf Erneut versuchen, oder laden Sie stattdessen eine Datei hoch."
+    },
+    "failed": {
+      "title": "Die Kamera konnte nicht starten",
+      "body": "Vielleicht verwendet eine andere App sie. Schließen Sie diese App und klicken Sie auf Erneut versuchen."
+    }
+  },
   "richText": {
     "openLinkHint": "{key} gedrückt halten und klicken, um zu öffnen"
   },

+ 29 - 0
messages/en/common.json

@@ -153,6 +153,35 @@
     "use": "Use this video",
     "limit": "Recording stops by itself after {minutes} minutes."
   },
+  "photoCapture": {
+    "title": "Take a photo",
+    "description": "Use this computer's camera or a webcam plugged into it.",
+    "starting": "Starting the camera...",
+    "live": "Camera preview",
+    "preview": "The photo you just took",
+    "camera": "Camera",
+    "take": "Take photo",
+    "retake": "Take again",
+    "use": "Use this photo",
+    "tryAgain": "Try again",
+    "uploadInstead": "Upload a file instead",
+    "denied": {
+      "title": "Camera access is blocked",
+      "body": "Allow the camera for this site in your browser, from the camera or lock icon in the address bar, then press Try again."
+    },
+    "insecure": {
+      "title": "The camera needs HTTPS",
+      "body": "Browsers only allow the camera on a secure connection, and this page is on HTTP ({host}). Open Torqvoice over HTTPS, or upload a file instead."
+    },
+    "notFound": {
+      "title": "No camera found",
+      "body": "Plug in a webcam and press Try again, or upload a file instead."
+    },
+    "failed": {
+      "title": "The camera could not start",
+      "body": "Another app may be using it. Close that app and press Try again."
+    }
+  },
   "richText": {
     "openLinkHint": "Hold {key} and click to open"
   },

+ 29 - 0
messages/es/common.json

@@ -153,6 +153,35 @@
     "use": "Usar este vídeo",
     "limit": "La grabación se detiene sola a los {minutes} minutos."
   },
+  "photoCapture": {
+    "title": "Hacer una foto",
+    "description": "Con la cámara de este ordenador o una webcam conectada.",
+    "starting": "Iniciando la cámara...",
+    "live": "Vista previa de la cámara",
+    "preview": "La foto que acaba de hacer",
+    "camera": "Cámara",
+    "take": "Hacer foto",
+    "retake": "Repetir",
+    "use": "Usar esta foto",
+    "tryAgain": "Reintentar",
+    "uploadInstead": "Subir un archivo",
+    "denied": {
+      "title": "El acceso a la cámara está bloqueado",
+      "body": "Permita la cámara para este sitio en su navegador, desde el icono de la cámara o del candado en la barra de direcciones, y pulse Reintentar."
+    },
+    "insecure": {
+      "title": "La cámara necesita HTTPS",
+      "body": "Los navegadores solo permiten la cámara con una conexión segura, y esta página está en HTTP ({host}). Abra Torqvoice por HTTPS, o suba un archivo."
+    },
+    "notFound": {
+      "title": "No se encontró ninguna cámara",
+      "body": "Conecte una webcam y pulse Reintentar, o suba un archivo."
+    },
+    "failed": {
+      "title": "No se pudo iniciar la cámara",
+      "body": "Puede que otra aplicación la esté usando. Ciérrela y pulse Reintentar."
+    }
+  },
   "richText": {
     "openLinkHint": "Mantén pulsado {key} y haz clic para abrir"
   },

+ 29 - 0
messages/fr/common.json

@@ -153,6 +153,35 @@
     "use": "Utiliser cette vidéo",
     "limit": "L'enregistrement s'arrête tout seul après {minutes} minutes."
   },
+  "photoCapture": {
+    "title": "Prendre une photo",
+    "description": "Avec la caméra de cet ordinateur ou une webcam branchée.",
+    "starting": "Démarrage de la caméra...",
+    "live": "Aperçu de la caméra",
+    "preview": "La photo que vous venez de prendre",
+    "camera": "Caméra",
+    "take": "Prendre la photo",
+    "retake": "Reprendre",
+    "use": "Utiliser cette photo",
+    "tryAgain": "Réessayer",
+    "uploadInstead": "Télécharger un fichier",
+    "denied": {
+      "title": "L'accès à la caméra est bloqué",
+      "body": "Autorisez la caméra pour ce site dans votre navigateur, depuis l'icône de caméra ou de cadenas dans la barre d'adresse, puis cliquez sur Réessayer."
+    },
+    "insecure": {
+      "title": "La caméra nécessite HTTPS",
+      "body": "Les navigateurs n'autorisent la caméra que sur une connexion sécurisée, et cette page est en HTTP ({host}). Ouvrez Torqvoice en HTTPS, ou téléchargez un fichier."
+    },
+    "notFound": {
+      "title": "Aucune caméra trouvée",
+      "body": "Branchez une webcam et cliquez sur Réessayer, ou téléchargez un fichier."
+    },
+    "failed": {
+      "title": "La caméra n'a pas pu démarrer",
+      "body": "Une autre application l'utilise peut-être. Fermez-la et cliquez sur Réessayer."
+    }
+  },
   "richText": {
     "openLinkHint": "Maintenez {key} et cliquez pour ouvrir"
   },

+ 29 - 0
messages/it/common.json

@@ -153,6 +153,35 @@
     "use": "Usa questo video",
     "limit": "La registrazione si ferma da sola dopo {minutes} minuti."
   },
+  "photoCapture": {
+    "title": "Scatta una foto",
+    "description": "Con la fotocamera di questo computer o una webcam collegata.",
+    "starting": "Avvio della fotocamera...",
+    "live": "Anteprima della fotocamera",
+    "preview": "La foto appena scattata",
+    "camera": "Fotocamera",
+    "take": "Scatta foto",
+    "retake": "Scatta di nuovo",
+    "use": "Usa questa foto",
+    "tryAgain": "Riprova",
+    "uploadInstead": "Carica un file",
+    "denied": {
+      "title": "L'accesso alla fotocamera è bloccato",
+      "body": "Consenti la fotocamera per questo sito nel browser, dall'icona della fotocamera o del lucchetto nella barra degli indirizzi, poi premi Riprova."
+    },
+    "insecure": {
+      "title": "La fotocamera richiede HTTPS",
+      "body": "I browser consentono la fotocamera solo su una connessione sicura, e questa pagina è in HTTP ({host}). Apri Torqvoice in HTTPS, oppure carica un file."
+    },
+    "notFound": {
+      "title": "Nessuna fotocamera trovata",
+      "body": "Collega una webcam e premi Riprova, oppure carica un file."
+    },
+    "failed": {
+      "title": "Impossibile avviare la fotocamera",
+      "body": "Forse un'altra app la sta usando. Chiudila e premi Riprova."
+    }
+  },
   "richText": {
     "openLinkHint": "Tieni premuto {key} e fai clic per aprire"
   },

+ 29 - 0
messages/lt/common.json

@@ -153,6 +153,35 @@
     "use": "Naudoti šį įrašą",
     "limit": "Įrašymas sustoja savaime po {minutes} min."
   },
+  "photoCapture": {
+    "title": "Padaryti nuotrauką",
+    "description": "Šio kompiuterio kamera arba prijungta internetine kamera.",
+    "starting": "Paleidžiama kamera...",
+    "live": "Kameros vaizdas",
+    "preview": "Ką tik padaryta nuotrauka",
+    "camera": "Kamera",
+    "take": "Fotografuoti",
+    "retake": "Fotografuoti iš naujo",
+    "use": "Naudoti šią nuotrauką",
+    "tryAgain": "Bandyti dar kartą",
+    "uploadInstead": "Įkelti failą",
+    "denied": {
+      "title": "Prieiga prie kameros užblokuota",
+      "body": "Leiskite naudoti kamerą šiai svetainei naršyklėje, paspaudę kameros arba spynos piktogramą adreso juostoje, tada spauskite Bandyti dar kartą."
+    },
+    "insecure": {
+      "title": "Kamerai reikia HTTPS",
+      "body": "Naršyklės leidžia naudoti kamerą tik saugiu ryšiu, o šis puslapis atidarytas per HTTP ({host}). Atidarykite Torqvoice per HTTPS arba įkelkite failą."
+    },
+    "notFound": {
+      "title": "Kamera nerasta",
+      "body": "Prijunkite internetinę kamerą ir spauskite Bandyti dar kartą arba įkelkite failą."
+    },
+    "failed": {
+      "title": "Nepavyko įjungti kameros",
+      "body": "Galbūt ją naudoja kita programa. Uždarykite ją ir spauskite Bandyti dar kartą."
+    }
+  },
   "richText": {
     "openLinkHint": "Laikykite {key} ir spustelėkite, kad atidarytumėte"
   },

+ 29 - 0
messages/nb/common.json

@@ -153,6 +153,35 @@
     "use": "Bruk denne videoen",
     "limit": "Opptaket stopper av seg selv etter {minutes} minutter."
   },
+  "photoCapture": {
+    "title": "Ta et bilde",
+    "description": "Med kameraet på denne datamaskinen eller et tilkoblet webkamera.",
+    "starting": "Starter kameraet...",
+    "live": "Forhåndsvisning fra kameraet",
+    "preview": "Bildet du nettopp tok",
+    "camera": "Kamera",
+    "take": "Ta bilde",
+    "retake": "Ta på nytt",
+    "use": "Bruk dette bildet",
+    "tryAgain": "Prøv igjen",
+    "uploadInstead": "Last opp en fil i stedet",
+    "denied": {
+      "title": "Tilgang til kameraet er blokkert",
+      "body": "Tillat kameraet for dette nettstedet i nettleseren, fra kamera- eller hengelåsikonet i adressefeltet, og trykk deretter Prøv igjen."
+    },
+    "insecure": {
+      "title": "Kameraet krever HTTPS",
+      "body": "Nettlesere tillater bare kameraet over en sikker tilkobling, og denne siden er på HTTP ({host}). Åpne Torqvoice over HTTPS, eller last opp en fil i stedet."
+    },
+    "notFound": {
+      "title": "Fant ikke noe kamera",
+      "body": "Koble til et webkamera og trykk Prøv igjen, eller last opp en fil i stedet."
+    },
+    "failed": {
+      "title": "Kameraet kunne ikke starte",
+      "body": "Kanskje en annen app bruker det. Lukk den appen og trykk Prøv igjen."
+    }
+  },
   "richText": {
     "openLinkHint": "Hold {key} og klikk for å åpne"
   },

+ 29 - 0
messages/nl/common.json

@@ -153,6 +153,35 @@
     "use": "Deze video gebruiken",
     "limit": "De opname stopt vanzelf na {minutes} minuten."
   },
+  "photoCapture": {
+    "title": "Een foto maken",
+    "description": "Met de camera van deze computer of een aangesloten webcam.",
+    "starting": "Camera wordt gestart...",
+    "live": "Cameravoorbeeld",
+    "preview": "De foto die u net hebt gemaakt",
+    "camera": "Camera",
+    "take": "Foto maken",
+    "retake": "Opnieuw maken",
+    "use": "Deze foto gebruiken",
+    "tryAgain": "Opnieuw proberen",
+    "uploadInstead": "Bestand uploaden",
+    "denied": {
+      "title": "Toegang tot de camera is geblokkeerd",
+      "body": "Sta de camera toe voor deze site in uw browser, via het camera- of slotpictogram in de adresbalk, en klik daarna op Opnieuw proberen."
+    },
+    "insecure": {
+      "title": "De camera heeft HTTPS nodig",
+      "body": "Browsers staan de camera alleen toe via een beveiligde verbinding, en deze pagina draait op HTTP ({host}). Open Torqvoice via HTTPS, of upload een bestand."
+    },
+    "notFound": {
+      "title": "Geen camera gevonden",
+      "body": "Sluit een webcam aan en klik op Opnieuw proberen, of upload een bestand."
+    },
+    "failed": {
+      "title": "De camera kon niet starten",
+      "body": "Misschien gebruikt een andere app hem. Sluit die app en klik op Opnieuw proberen."
+    }
+  },
   "richText": {
     "openLinkHint": "Houd {key} ingedrukt en klik om te openen"
   },

+ 29 - 0
messages/pl/common.json

@@ -153,6 +153,35 @@
     "use": "Użyj tego nagrania",
     "limit": "Nagrywanie zatrzyma się samo po {minutes} min."
   },
+  "photoCapture": {
+    "title": "Zdjęcie z kamery",
+    "description": "Kamerą tego komputera lub podłączoną kamerą internetową.",
+    "starting": "Uruchamianie kamery...",
+    "live": "Podgląd z kamery",
+    "preview": "Właśnie zrobione zdjęcie",
+    "camera": "Kamera",
+    "take": "Zrób zdjęcie",
+    "retake": "Zrób ponownie",
+    "use": "Użyj tego zdjęcia",
+    "tryAgain": "Spróbuj ponownie",
+    "uploadInstead": "Prześlij plik",
+    "denied": {
+      "title": "Dostęp do kamery jest zablokowany",
+      "body": "Zezwól tej stronie na użycie kamery w przeglądarce, ikoną kamery lub kłódki na pasku adresu, a potem kliknij Spróbuj ponownie."
+    },
+    "insecure": {
+      "title": "Kamera wymaga HTTPS",
+      "body": "Przeglądarki pozwalają używać kamery tylko przy bezpiecznym połączeniu, a ta strona działa przez HTTP ({host}). Otwórz Torqvoice przez HTTPS albo prześlij plik."
+    },
+    "notFound": {
+      "title": "Nie znaleziono kamery",
+      "body": "Podłącz kamerę internetową i kliknij Spróbuj ponownie albo prześlij plik."
+    },
+    "failed": {
+      "title": "Nie udało się uruchomić kamery",
+      "body": "Być może używa jej inna aplikacja. Zamknij ją i kliknij Spróbuj ponownie."
+    }
+  },
   "richText": {
     "openLinkHint": "Przytrzymaj {key} i kliknij, aby otworzyć"
   },

+ 29 - 0
messages/pt-BR/common.json

@@ -153,6 +153,35 @@
     "use": "Usar este vídeo",
     "limit": "A gravação para sozinha após {minutes} minutos."
   },
+  "photoCapture": {
+    "title": "Tirar uma foto",
+    "description": "Com a câmera deste computador ou uma webcam conectada.",
+    "starting": "Iniciando a câmera...",
+    "live": "Visualização da câmera",
+    "preview": "A foto que você acabou de tirar",
+    "camera": "Câmera",
+    "take": "Tirar foto",
+    "retake": "Tirar de novo",
+    "use": "Usar esta foto",
+    "tryAgain": "Tentar de novo",
+    "uploadInstead": "Enviar um arquivo",
+    "denied": {
+      "title": "O acesso à câmera está bloqueado",
+      "body": "Permita a câmera para este site no navegador, pelo ícone de câmera ou de cadeado na barra de endereço, e clique em Tentar de novo."
+    },
+    "insecure": {
+      "title": "A câmera precisa de HTTPS",
+      "body": "Os navegadores só liberam a câmera em uma conexão segura, e esta página está em HTTP ({host}). Abra o Torqvoice por HTTPS, ou envie um arquivo."
+    },
+    "notFound": {
+      "title": "Nenhuma câmera encontrada",
+      "body": "Conecte uma webcam e clique em Tentar de novo, ou envie um arquivo."
+    },
+    "failed": {
+      "title": "Não foi possível iniciar a câmera",
+      "body": "Talvez outro app esteja usando a câmera. Feche esse app e clique em Tentar de novo."
+    }
+  },
   "richText": {
     "openLinkHint": "Segure {key} e clique para abrir"
   },

+ 29 - 0
messages/pt-PT/common.json

@@ -153,6 +153,35 @@
     "use": "Usar este vídeo",
     "limit": "A gravação para sozinha ao fim de {minutes} minutos."
   },
+  "photoCapture": {
+    "title": "Tirar uma fotografia",
+    "description": "Com a câmara deste computador ou uma webcam ligada.",
+    "starting": "A iniciar a câmara...",
+    "live": "Pré-visualização da câmara",
+    "preview": "A fotografia que acabou de tirar",
+    "camera": "Câmara",
+    "take": "Tirar fotografia",
+    "retake": "Tirar novamente",
+    "use": "Usar esta fotografia",
+    "tryAgain": "Tentar novamente",
+    "uploadInstead": "Carregar um ficheiro",
+    "denied": {
+      "title": "O acesso à câmara está bloqueado",
+      "body": "Permita a câmara para este site no navegador, através do ícone da câmara ou do cadeado na barra de endereço, e clique em Tentar novamente."
+    },
+    "insecure": {
+      "title": "A câmara precisa de HTTPS",
+      "body": "Os navegadores só permitem a câmara numa ligação segura, e esta página está em HTTP ({host}). Abra o Torqvoice por HTTPS, ou carregue um ficheiro."
+    },
+    "notFound": {
+      "title": "Nenhuma câmara encontrada",
+      "body": "Ligue uma webcam e clique em Tentar novamente, ou carregue um ficheiro."
+    },
+    "failed": {
+      "title": "Não foi possível iniciar a câmara",
+      "body": "Talvez outra aplicação a esteja a usar. Feche essa aplicação e clique em Tentar novamente."
+    }
+  },
   "richText": {
     "openLinkHint": "Mantenha {key} premido e clique para abrir"
   },

+ 29 - 0
messages/ru/common.json

@@ -153,6 +153,35 @@
     "use": "Использовать это видео",
     "limit": "Запись остановится сама через {minutes} мин."
   },
+  "photoCapture": {
+    "title": "Сделать фото",
+    "description": "Камерой этого компьютера или подключённой веб-камерой.",
+    "starting": "Запуск камеры...",
+    "live": "Изображение с камеры",
+    "preview": "Только что сделанное фото",
+    "camera": "Камера",
+    "take": "Сфотографировать",
+    "retake": "Переснять",
+    "use": "Использовать это фото",
+    "tryAgain": "Повторить",
+    "uploadInstead": "Загрузить файл",
+    "denied": {
+      "title": "Доступ к камере заблокирован",
+      "body": "Разрешите этому сайту доступ к камере в браузере, через значок камеры или замка в адресной строке, а затем нажмите «Повторить»."
+    },
+    "insecure": {
+      "title": "Камере нужен HTTPS",
+      "body": "Браузеры дают доступ к камере только по защищённому соединению, а эта страница открыта по HTTP ({host}). Откройте Torqvoice по HTTPS или загрузите файл."
+    },
+    "notFound": {
+      "title": "Камера не найдена",
+      "body": "Подключите веб-камеру и нажмите «Повторить» или загрузите файл."
+    },
+    "failed": {
+      "title": "Не удалось включить камеру",
+      "body": "Возможно, её использует другое приложение. Закройте его и нажмите «Повторить»."
+    }
+  },
   "richText": {
     "openLinkHint": "Удерживайте {key} и нажмите, чтобы открыть"
   },

+ 29 - 0
messages/tr/common.json

@@ -153,6 +153,35 @@
     "use": "Bu videoyu kullan",
     "limit": "Kayıt {minutes} dakika sonra kendiliğinden durur."
   },
+  "photoCapture": {
+    "title": "Kamerayla fotoğraf çek",
+    "description": "Bu bilgisayarın kamerasıyla ya da bağlı bir web kamerasıyla.",
+    "starting": "Kamera başlatılıyor...",
+    "live": "Kamera önizlemesi",
+    "preview": "Az önce çekilen fotoğraf",
+    "camera": "Kamera",
+    "take": "Fotoğraf çek",
+    "retake": "Yeniden çek",
+    "use": "Bu fotoğrafı kullan",
+    "tryAgain": "Tekrar dene",
+    "uploadInstead": "Dosya yükle",
+    "denied": {
+      "title": "Kamera erişimi engellendi",
+      "body": "Tarayıcınızda bu site için kameraya izin verin (adres çubuğundaki kamera ya da kilit simgesinden), ardından Tekrar dene'ye basın."
+    },
+    "insecure": {
+      "title": "Kamera HTTPS gerektirir",
+      "body": "Tarayıcılar kameraya yalnızca güvenli bağlantıda izin verir ve bu sayfa HTTP ({host}) üzerinde. Torqvoice'u HTTPS ile açın ya da bir dosya yükleyin."
+    },
+    "notFound": {
+      "title": "Kamera bulunamadı",
+      "body": "Bir web kamerası bağlayıp Tekrar dene'ye basın ya da bir dosya yükleyin."
+    },
+    "failed": {
+      "title": "Kamera başlatılamadı",
+      "body": "Kamerayı başka bir uygulama kullanıyor olabilir. O uygulamayı kapatıp Tekrar dene'ye basın."
+    }
+  },
   "richText": {
     "openLinkHint": "Açmak için {key} tuşunu basılı tutup tıklayın"
   },

+ 246 - 0
src/__tests__/components/camera-photo-dialog.test.tsx

@@ -0,0 +1,246 @@
+/**
+ * Taking a photo with the desk computer's own camera.
+ *
+ * A desk browser ignores `capture` on a file input and shows the file picker,
+ * so the page takes the photo itself: the camera starts, the photo is looked
+ * at, and the one kept is handed over as a JPEG like an uploaded file. When the
+ * camera cannot be used, the person is told why and can still upload a file.
+ * A desk with a webcam plugged in keeps using that webcam.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { CameraPhotoDialog } from '@/components/camera-photo-dialog'
+
+type FakeTrack = { stop: ReturnType<typeof vi.fn>; getSettings: () => { deviceId: string } }
+
+function fakeStream(deviceId: string) {
+  const track: FakeTrack = { stop: vi.fn(), getSettings: () => ({ deviceId }) }
+  return { track, stream: { getTracks: () => [track], getVideoTracks: () => [track] } }
+}
+
+const getUserMedia = vi.fn()
+const enumerateDevices = vi.fn()
+
+function setSecure(secure: boolean) {
+  Object.defineProperty(window, 'isSecureContext', { value: secure, configurable: true })
+}
+
+function renderDialog() {
+  const onCaptured = vi.fn()
+  const onOpenChange = vi.fn()
+  const onUploadInstead = vi.fn()
+  render(
+    <CameraPhotoDialog
+      open
+      onOpenChange={onOpenChange}
+      onCaptured={onCaptured}
+      onUploadInstead={onUploadInstead}
+    />
+  )
+  return { onCaptured, onOpenChange, onUploadInstead }
+}
+
+/** The live picture arriving: the browser now knows the camera's size. */
+async function pictureArrives() {
+  const video = await screen.findByLabelText('Camera preview')
+  Object.defineProperty(video, 'videoWidth', { value: 1920, configurable: true })
+  Object.defineProperty(video, 'videoHeight', { value: 1080, configurable: true })
+  fireEvent.loadedMetadata(video)
+}
+
+beforeEach(() => {
+  setSecure(true)
+  getUserMedia.mockReset()
+  enumerateDevices
+    .mockReset()
+    .mockResolvedValue([{ kind: 'videoinput', deviceId: 'laptop', label: 'FaceTime HD Camera' }])
+  Object.defineProperty(navigator, 'mediaDevices', {
+    value: { getUserMedia, enumerateDevices },
+    configurable: true,
+  })
+  window.localStorage.clear()
+  URL.createObjectURL = vi.fn(() => 'blob:photo')
+  URL.revokeObjectURL = vi.fn()
+  vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+    drawImage: vi.fn(),
+  } as unknown as CanvasRenderingContext2D)
+  vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
+    this: HTMLCanvasElement,
+    callback: BlobCallback,
+    type?: string
+  ) {
+    callback(new Blob(['jpeg'], { type }))
+  })
+})
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+describe('CameraPhotoDialog', () => {
+  it('takes a photo, shows it, and hands it over as a JPEG when it is used', async () => {
+    const { track, stream } = fakeStream('laptop')
+    getUserMedia.mockResolvedValue(stream)
+    const { onCaptured, onOpenChange } = renderDialog()
+
+    const take = screen.getByRole('button', { name: 'Take photo' })
+    expect(take).toBeDisabled()
+    await pictureArrives()
+    await waitFor(() => expect(take).toBeEnabled())
+
+    await userEvent.click(take)
+    expect(await screen.findByAltText('The photo you just took')).toBeInTheDocument()
+    // The camera is off while the photo is looked at.
+    expect(track.stop).toHaveBeenCalled()
+
+    await userEvent.click(screen.getByRole('button', { name: 'Use this photo' }))
+    expect(onCaptured).toHaveBeenCalledTimes(1)
+    const file = onCaptured.mock.calls[0][0] as File
+    expect(file.type).toBe('image/jpeg')
+    expect(file.name).toMatch(/^photo-\d+\.jpg$/)
+    expect(onOpenChange).toHaveBeenCalledWith(false)
+  })
+
+  it('starts the camera again for another try, and keeps nothing from the first', async () => {
+    getUserMedia.mockImplementation(async () => fakeStream('laptop').stream)
+    const { onCaptured } = renderDialog()
+    await pictureArrives()
+    await userEvent.click(await screen.findByRole('button', { name: 'Take photo' }))
+
+    await userEvent.click(await screen.findByRole('button', { name: 'Take again' }))
+    expect(screen.queryByAltText('The photo you just took')).not.toBeInTheDocument()
+    expect(await screen.findByLabelText('Camera preview')).toBeInTheDocument()
+    await waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(2))
+    expect(onCaptured).not.toHaveBeenCalled()
+  })
+
+  it('says the camera is blocked and still lets a file be uploaded', async () => {
+    getUserMedia.mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError'))
+    const { onUploadInstead, onOpenChange } = renderDialog()
+
+    expect(await screen.findByRole('alert')).toHaveTextContent('Camera access is blocked')
+    expect(screen.queryByRole('button', { name: 'Take photo' })).not.toBeInTheDocument()
+
+    await userEvent.click(screen.getByRole('button', { name: 'Upload a file instead' }))
+    expect(onOpenChange).toHaveBeenCalledWith(false)
+    expect(onUploadInstead).toHaveBeenCalledTimes(1)
+  })
+
+  it('tries the camera again once it is allowed', async () => {
+    getUserMedia
+      .mockRejectedValueOnce(new DOMException('Permission denied', 'NotAllowedError'))
+      .mockResolvedValue(fakeStream('laptop').stream)
+    renderDialog()
+
+    await userEvent.click(await screen.findByRole('button', { name: 'Try again' }))
+    await pictureArrives()
+    expect(await screen.findByRole('button', { name: 'Take photo' })).toBeEnabled()
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+  })
+
+  it('says when no camera is connected', async () => {
+    getUserMedia.mockRejectedValue(new DOMException('Requested device not found', 'NotFoundError'))
+    renderDialog()
+    expect(await screen.findByRole('alert')).toHaveTextContent('No camera found')
+  })
+
+  it('explains that the camera needs HTTPS without asking the browser for it', async () => {
+    setSecure(false)
+    renderDialog()
+
+    const alert = await screen.findByRole('alert')
+    expect(alert).toHaveTextContent('The camera needs HTTPS')
+    expect(alert).toHaveTextContent(`HTTP (${window.location.hostname})`)
+    expect(getUserMedia).not.toHaveBeenCalled()
+    expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Upload a file instead' })).toBeInTheDocument()
+  })
+
+  it('uses the webcam picked last time', async () => {
+    window.localStorage.setItem('torqvoice.photo-camera', 'usb-webcam')
+    getUserMedia.mockResolvedValue(fakeStream('usb-webcam').stream)
+    renderDialog()
+
+    await waitFor(() => expect(getUserMedia).toHaveBeenCalled())
+    expect(getUserMedia.mock.calls[0][0].video.deviceId).toEqual({ exact: 'usb-webcam' })
+  })
+
+  it('falls back to the default camera when the webcam picked last time is unplugged', async () => {
+    window.localStorage.setItem('torqvoice.photo-camera', 'usb-webcam')
+    getUserMedia.mockImplementation(async (constraints: MediaStreamConstraints) => {
+      const video = constraints.video as MediaTrackConstraints
+      if (video.deviceId) throw new DOMException('gone', 'OverconstrainedError')
+      return fakeStream('laptop').stream
+    })
+    renderDialog()
+
+    await pictureArrives()
+    expect(await screen.findByRole('button', { name: 'Take photo' })).toBeEnabled()
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+    expect(window.localStorage.getItem('torqvoice.photo-camera')).toBeNull()
+  })
+
+  it('offers a choice of camera when a webcam is plugged in beside the built-in one', async () => {
+    getUserMedia.mockResolvedValue(fakeStream('laptop').stream)
+    enumerateDevices.mockResolvedValue([
+      { kind: 'videoinput', deviceId: 'laptop', label: 'FaceTime HD Camera' },
+      { kind: 'videoinput', deviceId: 'usb-webcam', label: 'Logitech C920' },
+      { kind: 'audioinput', deviceId: 'mic', label: 'Microphone' },
+    ])
+    renderDialog()
+
+    const picker = await screen.findByRole('combobox', { name: 'Camera' })
+    expect(picker).toHaveTextContent('FaceTime HD Camera')
+  })
+
+  it('keeps the camera working when the list of cameras cannot be read', async () => {
+    const { track, stream } = fakeStream('laptop')
+    getUserMedia.mockResolvedValue(stream)
+    enumerateDevices.mockRejectedValue(new DOMException('no list', 'NotSupportedError'))
+    renderDialog()
+
+    await pictureArrives()
+    expect(await screen.findByRole('button', { name: 'Take photo' })).toBeEnabled()
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+    expect(track.stop).not.toHaveBeenCalled()
+  })
+
+  it('turns the camera off when it fails after it has started', async () => {
+    const { track, stream } = fakeStream('laptop')
+    track.getSettings = () => {
+      throw new Error('track ended')
+    }
+    getUserMedia.mockResolvedValue(stream)
+    renderDialog()
+
+    expect(await screen.findByRole('alert')).toHaveTextContent('The camera could not start')
+    expect(track.stop).toHaveBeenCalled()
+  })
+
+  it('does not show a photo still being made when the dialog was closed', async () => {
+    getUserMedia.mockImplementation(async () => fakeStream('laptop').stream)
+    let finish: (() => void) | null = null
+    vi.mocked(HTMLCanvasElement.prototype.toBlob).mockImplementation(function (
+      this: HTMLCanvasElement,
+      callback: BlobCallback,
+      type?: string
+    ) {
+      finish = () => callback(new Blob(['jpeg'], { type }))
+    })
+    const props = { onOpenChange: vi.fn(), onCaptured: vi.fn() }
+    const { rerender } = render(<CameraPhotoDialog open {...props} />)
+    await pictureArrives()
+    await userEvent.click(await screen.findByRole('button', { name: 'Take photo' }))
+
+    rerender(<CameraPhotoDialog open={false} {...props} />)
+    act(() => finish?.())
+    rerender(<CameraPhotoDialog open {...props} />)
+
+    expect(await screen.findByLabelText('Camera preview')).toBeInTheDocument()
+    expect(screen.queryByAltText('The photo you just took')).not.toBeInTheDocument()
+    await pictureArrives()
+    expect(await screen.findByRole('button', { name: 'Take photo' })).toBeEnabled()
+  })
+})

+ 341 - 0
src/components/camera-photo-dialog.tsx

@@ -0,0 +1,341 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Camera, Loader2, RotateCcw, Upload } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+
+/** The camera picked last time, so a desk with a webcam plugged in keeps using it. */
+const CAMERA_KEY = 'torqvoice.photo-camera'
+/** Ask for a sharp picture; a camera that cannot do this gives its best. */
+const SIZE = { width: { ideal: 1920 }, height: { ideal: 1080 } }
+const JPEG_QUALITY = 0.92
+
+type Phase = 'starting' | 'ready' | 'review' | 'failed'
+/** Why there is no picture: each has its own sentence and way forward. */
+type Problem = 'denied' | 'insecure' | 'notFound' | 'failed'
+
+function errorName(err: unknown): string {
+  return err && typeof err === 'object' && 'name' in err ? String(err.name) : ''
+}
+
+function problemOf(err: unknown): Problem {
+  const name = errorName(err)
+  if (name === 'NotAllowedError' || name === 'SecurityError') return 'denied'
+  if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'notFound'
+  return 'failed'
+}
+
+function rememberedCamera(): string | null {
+  try {
+    return window.localStorage.getItem(CAMERA_KEY)
+  } catch {
+    return null
+  }
+}
+
+function rememberCamera(id: string | null) {
+  try {
+    if (id) window.localStorage.setItem(CAMERA_KEY, id)
+    else window.localStorage.removeItem(CAMERA_KEY)
+  } catch {
+    // Private windows may refuse storage; the default camera is used next time.
+  }
+}
+
+/** Opens the chosen camera, or the default one when the chosen one is unplugged. */
+async function openCamera(cameraId: string | null): Promise<MediaStream> {
+  if (cameraId) {
+    try {
+      return await navigator.mediaDevices.getUserMedia({
+        video: { deviceId: { exact: cameraId }, ...SIZE },
+      })
+    } catch (err) {
+      const name = errorName(err)
+      if (name !== 'OverconstrainedError' && name !== 'NotFoundError') throw err
+      rememberCamera(null)
+    }
+  }
+  return navigator.mediaDevices.getUserMedia({ video: SIZE })
+}
+
+/**
+ * Takes a photo with the computer's own camera or a webcam plugged into it,
+ * for the desk: a phone opens its camera app from a file input with
+ * `capture`, a desk browser ignores `capture` and shows the file picker, so
+ * the page takes the picture itself. The photo is looked at before it is
+ * used, and handed over as a JPEG file, exactly as if it had been uploaded.
+ *
+ * The preview is not mirrored, so a part number held up to the camera reads
+ * the right way round, on screen and in the photo.
+ */
+export function CameraPhotoDialog({
+  open,
+  onOpenChange,
+  onCaptured,
+  onUploadInstead,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onCaptured: (file: File) => void
+  /** Offered when the camera cannot be used, so the photo can still be added. */
+  onUploadInstead?: () => void
+}) {
+  const t = useTranslations('common.photoCapture')
+  const liveRef = useRef<HTMLVideoElement>(null)
+  const streamRef = useRef<MediaStream | null>(null)
+  const [phase, setPhase] = useState<Phase>('starting')
+  const [problem, setProblem] = useState<Problem | null>(null)
+  const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
+  // The camera picked in this dialog (otherwise the one used last time), and
+  // the one that actually answered.
+  const [cameraId, setCameraId] = useState<string | null>(null)
+  const [activeId, setActiveId] = useState<string | null>(null)
+  const [attempt, setAttempt] = useState(0)
+  const [shot, setShot] = useState<{ file: File; url: string } | null>(null)
+  // Bumped whenever the dialog closes, so a photo still being made from before
+  // is thrown away rather than shown when the dialog opens again.
+  const sessionRef = useRef(0)
+  const takingRef = useRef(false)
+
+  const stopStream = useCallback(() => {
+    for (const track of streamRef.current?.getTracks() ?? []) track.stop()
+    streamRef.current = null
+  }, [])
+
+  const dropShot = useCallback(() => {
+    setShot((current) => {
+      if (current) URL.revokeObjectURL(current.url)
+      return null
+    })
+  }, [])
+
+  // The camera runs while the dialog is open and waiting for a photo; it is
+  // switched off while the photo is looked at, and whenever the dialog closes.
+  const reviewing = phase === 'review'
+  useEffect(() => {
+    if (!open || reviewing) return
+    // Browsers only hand out the camera on HTTPS or localhost; asking would
+    // only fail, so say why straight away.
+    if (!window.isSecureContext) {
+      setProblem('insecure')
+      setPhase('failed')
+      return
+    }
+    if (!navigator.mediaDevices?.getUserMedia) {
+      setProblem('failed')
+      setPhase('failed')
+      return
+    }
+    let cancelled = false
+    async function start() {
+      setPhase('starting')
+      try {
+        const stream = await openCamera(cameraId ?? rememberedCamera())
+        if (cancelled) {
+          for (const track of stream.getTracks()) track.stop()
+          return
+        }
+        streamRef.current = stream
+        setActiveId(stream.getVideoTracks()[0]?.getSettings().deviceId ?? null)
+        if (liveRef.current) liveRef.current.srcObject = stream
+        // Names are only given out once the camera is allowed, so list them
+        // now. Without the list the camera still works, only without a picker.
+        const devices = await navigator.mediaDevices
+          .enumerateDevices()
+          .catch((): MediaDeviceInfo[] => [])
+        if (cancelled) return
+        setCameras(devices.filter((d) => d.kind === 'videoinput' && d.deviceId))
+        setProblem(null)
+      } catch (err) {
+        if (cancelled) return
+        // Nothing is left filming behind the message.
+        stopStream()
+        setProblem(problemOf(err))
+        setPhase('failed')
+      }
+    }
+    void start()
+    return () => {
+      cancelled = true
+      stopStream()
+    }
+  }, [open, cameraId, reviewing, attempt, stopStream])
+
+  // A dialog closed before the photo was used keeps nothing.
+  useEffect(() => {
+    if (open) return
+    sessionRef.current += 1
+    takingRef.current = false
+    dropShot()
+    setProblem(null)
+    setPhase('starting')
+  }, [open, dropShot])
+
+  function chooseCamera(id: string) {
+    rememberCamera(id)
+    setCameraId(id)
+  }
+
+  function takePhoto() {
+    const video = liveRef.current
+    if (takingRef.current || !video || !video.videoWidth || !video.videoHeight) return
+    const canvas = document.createElement('canvas')
+    canvas.width = video.videoWidth
+    canvas.height = video.videoHeight
+    const context = canvas.getContext('2d')
+    if (!context) return
+    context.drawImage(video, 0, 0, canvas.width, canvas.height)
+    takingRef.current = true
+    const session = sessionRef.current
+    canvas.toBlob(
+      (blob) => {
+        if (session !== sessionRef.current) return
+        takingRef.current = false
+        if (!blob) return
+        const file = new File([blob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' })
+        setShot({ file, url: URL.createObjectURL(blob) })
+        setPhase('review')
+      },
+      'image/jpeg',
+      JPEG_QUALITY
+    )
+  }
+
+  function takeAgain() {
+    dropShot()
+    setPhase('starting')
+  }
+
+  function tryAgain() {
+    setProblem(null)
+    setPhase('starting')
+    setAttempt((n) => n + 1)
+  }
+
+  function usePhoto() {
+    if (!shot) return
+    onCaptured(shot.file)
+    onOpenChange(false)
+  }
+
+  function uploadInstead() {
+    onOpenChange(false)
+    onUploadInstead?.()
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-2xl" data-testid="camera-photo">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+
+        {phase === 'failed' ? (
+          <div
+            role="alert"
+            className="space-y-1 rounded-md border border-destructive/30 bg-destructive/5 p-3"
+          >
+            <p className="text-sm font-medium">{t(`${problem ?? 'failed'}.title`)}</p>
+            <p className="text-sm text-muted-foreground">
+              {problem === 'insecure'
+                ? t('insecure.body', { host: window.location.hostname })
+                : t(`${problem ?? 'failed'}.body`)}
+            </p>
+          </div>
+        ) : (
+          <div className="relative overflow-hidden rounded-lg bg-black">
+            {phase === 'review' && shot ? (
+              <img
+                src={shot.url}
+                alt={t('preview')}
+                className="aspect-video w-full object-contain"
+              />
+            ) : (
+              <video
+                ref={liveRef}
+                autoPlay
+                muted
+                playsInline
+                aria-label={t('live')}
+                className="aspect-video w-full object-contain"
+                onLoadedMetadata={() => setPhase((p) => (p === 'starting' ? 'ready' : p))}
+              />
+            )}
+            {phase === 'starting' && (
+              <div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-white">
+                <Loader2 className="h-4 w-4 animate-spin" />
+                {t('starting')}
+              </div>
+            )}
+          </div>
+        )}
+
+        {cameras.length > 1 && (phase === 'ready' || phase === 'starting') && (
+          <Select value={activeId ?? cameras[0]?.deviceId} onValueChange={chooseCamera}>
+            <SelectTrigger aria-label={t('camera')} className="w-full sm:w-72">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {cameras.map((camera, index) => (
+                <SelectItem key={camera.deviceId} value={camera.deviceId}>
+                  {camera.label || `${t('camera')} ${index + 1}`}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        )}
+
+        <DialogFooter>
+          {phase === 'review' ? (
+            <>
+              <Button type="button" variant="outline" onClick={takeAgain}>
+                <RotateCcw className="mr-1.5 h-4 w-4" />
+                {t('retake')}
+              </Button>
+              <Button type="button" onClick={usePhoto}>
+                {t('use')}
+              </Button>
+            </>
+          ) : phase === 'failed' ? (
+            <>
+              {onUploadInstead && (
+                <Button type="button" variant="outline" onClick={uploadInstead}>
+                  <Upload className="mr-1.5 h-4 w-4" />
+                  {t('uploadInstead')}
+                </Button>
+              )}
+              {problem !== 'insecure' && (
+                <Button type="button" onClick={tryAgain}>
+                  <RotateCcw className="mr-1.5 h-4 w-4" />
+                  {t('tryAgain')}
+                </Button>
+              )}
+            </>
+          ) : (
+            <Button type="button" onClick={takePhoto} disabled={phase !== 'ready'}>
+              <Camera className="mr-1.5 h-4 w-4" />
+              {t('take')}
+            </Button>
+          )}
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 15 - 1
src/features/inventory/Components/InventoryPartForm.tsx

@@ -56,6 +56,8 @@ import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
 import { clearableInput } from '@/lib/clearable'
 import { TagInput } from '@/components/tag-input'
 import { useServiceType } from '@/components/service-type-context'
+import { CameraPhotoDialog } from '@/components/camera-photo-dialog'
+import { useNativeCamera } from '@/hooks/use-native-camera'
 import {
   MAX_PART_TAGS,
   MAX_TAG_LENGTH,
@@ -150,6 +152,10 @@ export function InventoryPartForm({
   const formRef = useRef<HTMLFormElement>(null)
   const fileInputRef = useRef<HTMLInputElement>(null)
   const cameraInputRef = useRef<HTMLInputElement>(null)
+  // A phone opens its camera app from the capture input; a desk browser would
+  // only show the file picker, so it gets the page's own camera instead.
+  const nativeCamera = useNativeCamera()
+  const [cameraOpen, setCameraOpen] = useState(false)
   const uploadedUrlsRef = useRef<string[]>([])
 
   useEffect(() => {
@@ -608,7 +614,9 @@ export function InventoryPartForm({
                     variant="outline"
                     size="sm"
                     className="flex-1 text-xs"
-                    onClick={() => cameraInputRef.current?.click()}
+                    onClick={() =>
+                      nativeCamera ? cameraInputRef.current?.click() : setCameraOpen(true)
+                    }
                     disabled={uploading}
                   >
                     <Camera className="mr-1 h-3 w-3" />
@@ -626,6 +634,12 @@ export function InventoryPartForm({
                     {t('form.upload')}
                   </Button>
                 </div>
+                <CameraPhotoDialog
+                  open={cameraOpen}
+                  onOpenChange={setCameraOpen}
+                  onCaptured={(file) => void uploadFile(file)}
+                  onUploadInstead={() => fileInputRef.current?.click()}
+                />
                 {gallery.length > 0 && (
                   <Button
                     type="button"