feat: add HEIC image support for iPhone uploads
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"@supabase/supabase-js": "^2.39.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"canvas-confetti": "^1.9.2",
|
"canvas-confetti": "^1.9.2",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"lucide-react": "^0.300.0",
|
"lucide-react": "^0.300.0",
|
||||||
"next": "14.2.23",
|
"next": "14.2.23",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './tests/e2e',
|
|
||||||
/* Run tests in files in parallel */
|
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
||||||
reporter: 'html',
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
baseURL: 'http://localhost:3000',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
|
||||||
webServer: {
|
|
||||||
command: 'npm run dev',
|
|
||||||
url: 'http://localhost:3000',
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
canvas-confetti:
|
canvas-confetti:
|
||||||
specifier: ^1.9.2
|
specifier: ^1.9.2
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
|
heic2any:
|
||||||
|
specifier: ^0.0.4
|
||||||
|
version: 0.0.4
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.300.0
|
specifier: ^0.300.0
|
||||||
version: 0.300.0(react@18.3.1)
|
version: 0.300.0(react@18.3.1)
|
||||||
@@ -1221,6 +1224,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
heic2any@0.0.4:
|
||||||
|
resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
|
||||||
|
|
||||||
iceberg-js@0.8.1:
|
iceberg-js@0.8.1:
|
||||||
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
|
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -3350,6 +3356,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
heic2any@0.0.4: {}
|
||||||
|
|
||||||
iceberg-js@0.8.1: {}
|
iceberg-js@0.8.1: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|||||||
@@ -45,7 +45,28 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
setMatchingBottle(null);
|
setMatchingBottle(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const compressedBase64 = await compressImage(file);
|
let fileToProcess = file;
|
||||||
|
|
||||||
|
// HEIC / HEIF Check
|
||||||
|
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' || file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||||
|
|
||||||
|
if (isHeic) {
|
||||||
|
console.log('HEIC detected, converting...');
|
||||||
|
const heic2any = (await import('heic2any')).default;
|
||||||
|
const convertedBlob = await heic2any({
|
||||||
|
blob: file,
|
||||||
|
toType: 'image/jpeg',
|
||||||
|
quality: 0.8
|
||||||
|
});
|
||||||
|
|
||||||
|
// heic2any can return an array if the file contains multiple images
|
||||||
|
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
||||||
|
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
||||||
|
type: 'image/jpeg'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressedBase64 = await compressImage(fileToProcess);
|
||||||
setPreviewUrl(compressedBase64);
|
setPreviewUrl(compressedBase64);
|
||||||
|
|
||||||
if (onImageCaptured) {
|
if (onImageCaptured) {
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { saveTasting } from '../save-tasting';
|
|
||||||
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
|
|
||||||
|
|
||||||
// Mock Supabase
|
|
||||||
vi.mock('@supabase/auth-helpers-nextjs', () => ({
|
|
||||||
createServerActionClient: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock next/headers
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock next/cache
|
|
||||||
vi.mock('next/cache', () => ({
|
|
||||||
revalidatePath: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('saveTasting', () => {
|
|
||||||
const mockInsert = vi.fn();
|
|
||||||
const mockSelect = vi.fn();
|
|
||||||
const mockSingle = vi.fn();
|
|
||||||
const mockGetSession = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
const mockSupabase = {
|
|
||||||
auth: {
|
|
||||||
getSession: mockGetSession,
|
|
||||||
},
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
insert: mockInsert,
|
|
||||||
select: mockSelect,
|
|
||||||
eq: vi.fn(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockInsert.mockReturnValue({ select: mockSelect });
|
|
||||||
mockSelect.mockReturnValue({ single: mockSingle });
|
|
||||||
|
|
||||||
(createServerActionClient as any).mockReturnValue(mockSupabase);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save a tasting note and tags', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
data: { session: { user: { id: 'user-123' } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSingle.mockResolvedValue({
|
|
||||||
data: { id: 'tasting-456' },
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the second insert for tags
|
|
||||||
const mockTagInsert = vi.fn().mockResolvedValue({ error: null });
|
|
||||||
(createServerActionClient({ cookies: {} as any }).from as any).mockImplementation((table: string) => {
|
|
||||||
if (table === 'tastings') {
|
|
||||||
return { insert: mockInsert, select: mockSelect };
|
|
||||||
}
|
|
||||||
return { insert: mockTagInsert };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await saveTasting({
|
|
||||||
bottle_id: 'bottle-789',
|
|
||||||
rating: 90,
|
|
||||||
buddy_ids: ['buddy-1', 'buddy-2'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(mockInsert).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
bottle_id: 'bottle-789',
|
|
||||||
rating: 90,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Check tags were inserted with user_id (the fix for recursion)
|
|
||||||
expect(mockTagInsert).toHaveBeenCalledWith(expect.arrayContaining([
|
|
||||||
expect.objectContaining({ buddy_id: 'buddy-1', user_id: 'user-123' }),
|
|
||||||
expect.objectContaining({ buddy_id: 'buddy-2', user_id: 'user-123' }),
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import '@testing-library/jest-dom';
|
|
||||||
import { expect, afterEach } from 'vitest';
|
|
||||||
import { cleanup } from '@testing-library/react';
|
|
||||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
|
||||||
|
|
||||||
expect.extend(matchers);
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test.describe('Authentication', () => {
|
|
||||||
test('should show error on invalid login', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await page.getByPlaceholder('name@beispiel.de').fill('wrong@example.com');
|
|
||||||
await page.getByPlaceholder('••••••••').fill('wrongpassword');
|
|
||||||
await page.getByRole('button', { name: 'Einloggen' }).click();
|
|
||||||
|
|
||||||
// Expect error message
|
|
||||||
// Note: This content depends on Supabase error message
|
|
||||||
await expect(page.locator('div:has-text("Invalid login credentials")')).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test.describe('Collection View', () => {
|
|
||||||
test('should show empty state message if no bottles found', async ({ page }) => {
|
|
||||||
// This test assumes a clean or specific state.
|
|
||||||
// In a real environment, we'd use a test user with 0 bottles.
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// If we can't control the user, we at least check that we don't see the technical error message
|
|
||||||
const errorBox = page.locator('text=Hoppla');
|
|
||||||
await expect(errorBox).not.toBeVisible();
|
|
||||||
|
|
||||||
// Check for either the grid or the empty state message
|
|
||||||
const emptyMessage = page.locator('text=Noch keine Flaschen im Vault');
|
|
||||||
const bottleGrid = page.locator('.grid');
|
|
||||||
|
|
||||||
const isGridVisible = await bottleGrid.isVisible();
|
|
||||||
if (!isGridVisible) {
|
|
||||||
await expect(emptyMessage).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('has title and login form', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// Expect a title "to contain" a substring.
|
|
||||||
await expect(page.locator('h1')).toContainText('WHISKYVAULT');
|
|
||||||
|
|
||||||
// Expect login form to be visible
|
|
||||||
await expect(page.getByPlaceholder('name@beispiel.de')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Einloggen' })).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: ['./src/tests/setup.ts'],
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user