diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fdd801 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# OpenAI API key +OPENAI_KEY=your-openai-key-here + +# Database credentials +DB_HOST=localhost +DB_USER=your-db-user +DB_PASSWORD=your-db-password +DB_NAME=your-db-name + +# Admin credentials +ADMIN_USER=your-admin-username +ADMIN_PASSWORD=your-admin-password \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b4a50bf..d60adfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,11 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "dotenv": "^17.2.3", "lucide-react": "^0.292.0", + "mysql2": "^3.15.3", "next": "^14.2.33", + "openai": "^6.7.0", "react": "18.2.0", "react-day-picker": "^8.9.1", "react-dom": "18.2.0", @@ -1399,6 +1402,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", @@ -1900,6 +1912,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1940,6 +1961,18 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2927,6 +2960,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -3234,6 +3276,22 @@ "node": ">= 0.4" } }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3595,6 +3653,12 @@ "node": ">=8" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3954,6 +4018,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3976,6 +4046,21 @@ "node": "20 || >=22" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lucide-react": { "version": "0.292.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", @@ -4059,6 +4144,26 @@ "dev": true, "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4071,6 +4176,27 @@ "thenify-all": "^1.0.0" } }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -4360,6 +4486,27 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.7.0.tgz", + "integrity": "sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5041,6 +5188,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5063,6 +5216,11 @@ "node": ">=10" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5243,6 +5401,15 @@ "node": ">=0.10.0" } }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", diff --git a/package.json b/package.json index 964bfd8..e2012ea 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "next dev", "build": "next build", + "build:scripts": "tsc -p scripts/tsconfig.json", + "migrate": "npm run build:scripts && node dist/scripts/db-migration.js", "start": "node server.mjs", "lint": "next lint", "clean": "rimraf .next node_modules logs && npm install", @@ -20,8 +22,11 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "dotenv": "^17.2.3", "lucide-react": "^0.292.0", + "mysql2": "^3.15.3", "next": "^14.2.33", + "openai": "^6.7.0", "react": "18.2.0", "react-day-picker": "^8.9.1", "react-dom": "18.2.0", diff --git a/public/img/brunch.jpg b/public/img/brunch.jpg index b607fb7..1e24db1 100644 Binary files a/public/img/brunch.jpg and b/public/img/brunch.jpg differ diff --git a/public/img/fransk.jpg b/public/img/fransk.jpg index ff58e90..eb1c12f 100644 Binary files a/public/img/fransk.jpg and b/public/img/fransk.jpg differ diff --git a/public/img/fusion.jpg b/public/img/fusion.jpg new file mode 100644 index 0000000..34497f3 Binary files /dev/null and b/public/img/fusion.jpg differ diff --git a/public/img/gourmet.jpg b/public/img/gourmet.jpg new file mode 100644 index 0000000..b852a1d Binary files /dev/null and b/public/img/gourmet.jpg differ diff --git a/public/img/grill.jpg b/public/img/grill.jpg index 5c4f614..dde751f 100644 Binary files a/public/img/grill.jpg and b/public/img/grill.jpg differ diff --git a/public/img/italiensk.jpg b/public/img/italiensk.jpg index 49dfec5..3334d81 100644 Binary files a/public/img/italiensk.jpg and b/public/img/italiensk.jpg differ diff --git a/public/img/jul.jpg b/public/img/jul.jpg index 7006fd5..327642a 100644 Binary files a/public/img/jul.jpg and b/public/img/jul.jpg differ diff --git a/public/img/klassisk.jpg b/public/img/klassisk.jpg index f33d1e2..84f475f 100644 Binary files a/public/img/klassisk.jpg and b/public/img/klassisk.jpg differ diff --git a/public/img/nordisk.jpg b/public/img/nordisk.jpg index bb5291c..d9131ea 100644 Binary files a/public/img/nordisk.jpg and b/public/img/nordisk.jpg differ diff --git a/public/img/sushi.jpg b/public/img/sushi.jpg new file mode 100644 index 0000000..ab3d413 Binary files /dev/null and b/public/img/sushi.jpg differ diff --git a/public/img/tapas.jpg b/public/img/tapas.jpg index 7b663c4..eb3842b 100644 Binary files a/public/img/tapas.jpg and b/public/img/tapas.jpg differ diff --git a/public/img/veg.jpg b/public/img/veg.jpg index 3d151e5..1041184 100644 Binary files a/public/img/veg.jpg and b/public/img/veg.jpg differ diff --git a/scripts/db-migration.mjs b/scripts/db-migration.mjs new file mode 100644 index 0000000..03685ca --- /dev/null +++ b/scripts/db-migration.mjs @@ -0,0 +1,97 @@ +import mysql from 'mysql2/promise'; +import { sampleData } from '../src/config/sample-data.js'; + +// Database pool configuration +const pool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 10, + idleTimeout: 60000, + queueLimit: 0, +}); + +function menuToRow(menu) { + return { + id: menu.id, + title: menu.title, + description: menu.description, + courses: JSON.stringify(menu.courses), + tags: JSON.stringify(menu.tags), + image: menu.image, + base_ingredients_per_cover: menu.baseIngredientsPerCover, + ingredients_cost_index: menu.ingredientsCostIndex, + base_labour_hours: menu.baseLabourHours, + incremental_labour_per_cover: menu.incrementalLabourPerCover, + min_covers: menu.minCovers, + lead_time_days: menu.leadTimeDays, + base_price_per_cover: menu.basePricePerCover, + }; +} + +async function createTables() { + const connection = await pool.getConnection(); + + try { + // Create menus table + await connection.query(` + CREATE TABLE IF NOT EXISTS menus ( + id VARCHAR(255) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + courses JSON NOT NULL, + tags JSON NOT NULL, + image VARCHAR(255), + base_ingredients_per_cover DECIMAL(10,2) NOT NULL, + ingredients_cost_index DECIMAL(10,2) NOT NULL, + base_labour_hours DECIMAL(10,2) NOT NULL, + incremental_labour_per_cover DECIMAL(10,2) NOT NULL, + min_covers INT NOT NULL, + lead_time_days INT NOT NULL, + base_price_per_cover DECIMAL(10,2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ); + `); + + // Check if menus table is empty + const [result] = await connection.query('SELECT COUNT(*) as count FROM menus'); + const rows = result; + const count = rows[0].count; + + // If empty, insert sample data + if (count === 0) { + console.log('Inserting sample data...'); + const processedIds = new Set(); + + for (const menu of sampleData.menus) { + // Skip if we've already processed this ID + if (processedIds.has(menu.id)) continue; + processedIds.add(menu.id); + + const row = menuToRow(menu); + await connection.query( + 'INSERT INTO menus SET ?', + row + ); + } + console.log('Sample data inserted successfully'); + } + + } finally { + connection.release(); + await pool.end(); + } +} + +// Kør migrationen +createTables().then(() => { + console.log('Database migration completed'); + process.exit(0); +}).catch((error) => { + console.error('Error during migration:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/generate-images.mjs b/scripts/generate-images.mjs new file mode 100644 index 0000000..0e11184 --- /dev/null +++ b/scripts/generate-images.mjs @@ -0,0 +1,242 @@ +import OpenAI from 'openai'; +import fs from 'fs/promises'; +import path from 'path'; +import dotenv from 'dotenv'; +import crypto from 'crypto'; + +dotenv.config(); + +// Helper til at generere en konsistent hash af en prompt +function hashPrompt(prompt) { + return crypto.createHash('md5').update(prompt).digest('hex'); +} + +// Cache struktur til at gemme prompts og deres resultater +async function loadCache() { + try { + const cache = await fs.readFile('scripts/image-cache.json', 'utf8'); + return JSON.parse(cache); + } catch { + return {}; + } +} + +async function saveCache(cache) { + await fs.writeFile('scripts/image-cache.json', JSON.stringify(cache, null, 2)); +} + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_KEY +}); + +const prompts = [ + { + name: 'klassisk', + prompt: `High-end food photography of an elegant plated dish: + - Hot-smoked salmon with cream cheese, fresh dill, and thinly sliced radishes + - Beautifully arranged on a modern white ceramic plate + - Natural, directional lighting emphasizing textures + - Shallow depth of field focusing on the salmon + - Professional restaurant-quality presentation + - Garnished with micro herbs and edible flowers + - 4K quality, shot with a high-end camera` + }, + { + name: 'veg', + prompt: `Professional food photography of a colorful vegetarian main course: + - Grilled asparagus arranged artfully + - Creamy mushroom risotto with parmesan + - Edible flowers and micro greens as garnish + - Shot from above at a slight angle + - Natural window lighting + - White ceramic plate on a light wooden surface + - Vibrant colors and appealing textures + - High-resolution, restaurant quality presentation` + }, + { + name: 'grill', + prompt: `Dramatic food photography of grilled dishes: + - Perfectly grilled medium-rare flank steak with visible grill marks + - Colorful grilled vegetables alongside + - Visible steam or smoke + - Rich, warm lighting emphasizing caramelization + - Shot at a 45-degree angle + - Dark moody background + - Professional food styling with herbs and sea salt + - High-end restaurant presentation quality` + }, + { + name: 'nordisk', + prompt: `Nordic cuisine presentation: + - Modern interpretation of traditional Nordic dishes + - Cold-smoked salmon and pickled herring + - Fresh rye bread and traditional accompaniments + - Clean, minimal styling on matte black plates + - Bright natural lighting + - Garnished with fresh dill and edible flowers + - Shot from above with some height + - Professional restaurant quality presentation` + }, + { + name: 'tapas', + prompt: `Spanish tapas spread photography: + - Beautifully arranged selection of Spanish tapas + - Serrano ham, manchego cheese, and olives + - Patatas bravas with vibrant sauce + - Traditional ceramic plates and wooden boards + - Warm, Mediterranean-inspired lighting + - Garnished with fresh herbs and olive oil + - Rustic yet elegant presentation + - Shot from a 45-degree angle` + }, + { + name: 'brunch', + prompt: `Luxury brunch setting photography: + - Elegant brunch spread with fresh pastries + - Perfectly cooked scrambled eggs with herbs + - Fresh fruits and berries + - Artisanal bread selection + - Bright, morning lighting + - White linens and premium tableware + - Fresh flowers as decoration + - High-end restaurant quality presentation` + }, + { + name: 'italiensk', + prompt: `Italian cuisine photography: + - Authentic Italian antipasti spread + - Fresh bruschetta with vibrant tomatoes + - Selection of Italian cured meats + - Artfully arranged on rustic wooden board + - Warm, directional lighting + - Fresh basil and olive oil garnish + - Rustic Italian restaurant atmosphere + - Professional food styling` + }, + { + name: 'gourmet', + prompt: `High-end gourmet dish photography: + - Elegant beef tenderloin dish with truffle + - Modern plating on dark ceramic plate + - Artistic sauce presentation + - Micro herbs and edible flowers + - Dramatic lighting emphasizing textures + - Shallow depth of field + - Michelin-star quality presentation + - Shot with professional camera setup` + }, + { + name: 'fransk', + prompt: `French haute cuisine photography: + - Elegant plating of foie gras and brioche + - Duck confit with pommes sarladaises + - Sophisticated sauce work and garnishes + - Premium porcelain plate with gold rim + - Professional studio lighting + - Shallow depth of field + - Michelin-star presentation style + - Garnished with edible flowers and micro herbs` + }, + { + name: 'jul', + prompt: `Nordic Christmas dinner photography: + - Elegant presentation of traditional Danish Christmas dishes + - Beautifully arranged confited duck leg + - Caramelized potatoes and red cabbage + - Classic white porcelain plate + - Warm, festive lighting with candle glow + - Garnished with fresh herbs + - Holiday atmosphere but modern presentation + - Professional food photography style` + }, + { + name: 'fusion', + prompt: `Modern Asian fusion food photography: + - Contemporary Asian fusion dish + - Korean style short ribs with modern plating + - Colorful Asian vegetables and garnishes + - Dramatic black ceramic plate + - Professional studio lighting + - Garnished with micro herbs and sesame + - Artistic sauce presentation + - High-end restaurant quality styling` + } + ];async function generateImages() { + const cache = await loadCache(); + + for (const { name, prompt } of prompts) { + const promptHash = hashPrompt(prompt); + const imagePath = path.join(process.cwd(), 'public', 'img', `${name}.jpg`); + + try { + // Tjek om billedet allerede eksisterer + try { + await fs.access(imagePath); + // Hvis billedet findes og prompten er cached, spring over + if (cache[promptHash]) { + console.log(`✓ Skipping ${name}.jpg (already exists with same prompt)`); + continue; + } + } catch { + // Billedet findes ikke, vi skal generere det + } + + console.log(`Generating image for ${name}...`); + + const response = await openai.images.generate({ + model: "dall-e-3", + prompt: prompt, + n: 1, + size: "1024x1024", + quality: "hd", + style: "natural" + }); + + const imageUrl = response.data[0].url; + + // Download billedet + const imageResponse = await fetch(imageUrl); + const buffer = await imageResponse.arrayBuffer(); + + // Gem billedet + await fs.mkdir(path.join(process.cwd(), 'public', 'img'), { recursive: true }); + await fs.writeFile(imagePath, Buffer.from(buffer)); + + // Gem prompt i cache + cache[promptHash] = { + name, + timestamp: new Date().toISOString() + }; + await saveCache(cache); + + console.log(`✅ Generated and saved ${name}.jpg`); + } catch (error) { + console.error(`❌ Error generating ${name}:`, error); + } + } +} + +// Håndter afbrydelse af scriptet +async function cleanup() { + console.log('\nSaving cache and cleaning up...'); + try { + await saveCache(await loadCache()); + console.log('Cache saved successfully'); + } catch (error) { + console.error('Error saving cache:', error); + } + process.exit(0); +} + +// Lyt efter SIGINT (Ctrl+C) +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + +// Run the script +generateImages().then(() => { + console.log('All done!'); + process.exit(0); +}).catch(error => { + console.error('Error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/image-cache.json b/scripts/image-cache.json new file mode 100644 index 0000000..5aa2461 --- /dev/null +++ b/scripts/image-cache.json @@ -0,0 +1,50 @@ +{ + "84d2dafd3423262638462d1699aa8afa": { + "name": "klassisk", + "timestamp": "2025-10-29T08:29:59.390Z" + }, + "b681112823428f6e2c0203e7c9882126": { + "name": "veg", + "timestamp": "2025-10-29T08:30:23.376Z" + }, + "f4e5d6174b4ef5b828200bb7b3317679": { + "name": "grill", + "timestamp": "2025-10-29T08:30:43.291Z" + }, + "af367c130586f311d0fb5b6792ae805c": { + "name": "nordisk", + "timestamp": "2025-10-29T08:31:01.142Z" + }, + "4cde815a2be30ede2b82392de3b70d05": { + "name": "tapas", + "timestamp": "2025-10-29T08:34:38.360Z" + }, + "d607637731b025d42727839d8a10d352": { + "name": "brunch", + "timestamp": "2025-10-29T08:34:55.023Z" + }, + "376323a7b1ad4e66d42736670827b422": { + "name": "italiensk", + "timestamp": "2025-10-29T08:35:19.481Z" + }, + "37f9851d91ca6c16cb95ec066808d4cf": { + "name": "gourmet", + "timestamp": "2025-10-29T08:35:37.504Z" + }, + "fcea12be02ead621c8700727d69d3669": { + "name": "fransk", + "timestamp": "2025-10-29T08:36:06.895Z" + }, + "0c5c2d79faa3b8b7b5821728768a3177": { + "name": "jul", + "timestamp": "2025-10-29T08:36:32.884Z" + }, + "d5c1624e2dd9efeaa93a3a769e6550de": { + "name": "sushi", + "timestamp": "2025-10-29T09:08:34.589Z" + }, + "c65bb5f37b939b00b1cc68ebabf4142b": { + "name": "fusion", + "timestamp": "2025-10-29T09:08:54.036Z" + } +} \ No newline at end of file diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..bf4eaa3 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "outDir": "../dist", + "rootDir": "..", + "strict": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["../src/*"] + } + }, + "include": [ + "*.ts", + "*.mts" + ] +} \ No newline at end of file diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx deleted file mode 100644 index 82eb066..0000000 --- a/src/app/(admin)/admin/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Calendar, Users, FileText, Settings, ChefHat } from 'lucide-react' -import { CalendarView } from '@/components/admin/calendar-view' -import { BookingsView } from '@/components/admin/bookings-view' -import { siteConfig } from '@/config/site' - -const sections = [ - { id: 'calendar', icon: Calendar, label: 'Kalender' }, - { id: 'bookings', icon: Users, label: 'Bookinger' }, - { id: 'quotes', icon: FileText, label: 'Tilbud' }, - { id: 'settings', icon: Settings, label: 'Indstillinger' } -] as const - -type Section = (typeof sections)[number]['id'] - -export default function AdminDashboard() { - const [activeSection, setActiveSection] = useState
('calendar') - - return ( -
-
- {/* Sidebar */} -
-
-
- -

{siteConfig.brand.displayName}

-
-
- -
- - {/* Main Content */} -
-
- {activeSection === 'calendar' && } - {activeSection === 'bookings' && } - - {activeSection === 'quotes' && ( -
-
-

- - Tilbud -

-
- -
-
-

Aktive tilbud

-
-
-
-
-

Anders Andersen

-

Fødselsdag • 45 kuverter

-
-

22.950 kr.

-
-
- - -
-
-
-
-
-
- )} - - {activeSection === 'settings' && ( -
-
-

- - Indstillinger -

-
- -
-
-

Priser og satser

-
-
- - -
-
- - -
-
-
- -
-

Integration

-
-
- - -
- -
-
-
-
- )} -
-
-
-
- ) -} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..febb3ba --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,367 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Menu } from '@/types/models' +import { ImagePlaceholder } from '@/components/ui/image-placeholder' + +export default function AdminPage() { + const [menus, setMenus] = useState([]) + const [editingMenu, setEditingMenu] = useState(null) + const [showForm, setShowForm] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetchMenus() + }, []) + + const fetchMenus = async () => { + try { + const response = await fetch('/api/menus') + if (!response.ok) throw new Error('Failed to fetch menus') + const data = await response.json() + setMenus(data) + } catch (err) { + setError('Der skete en fejl ved indlæsning af menuer') + console.error(err) + } finally { + setLoading(false) + } + } + + const defaultMenu: Menu = { + id: '', + title: '', + description: '', + courses: ['', '', ''], + tags: [], + image: '', + baseIngredientsPerCover: 65, + ingredientsCostIndex: 1.0, + baseLabourHours: 4, + incrementalLabourPerCover: 0.05, + minCovers: 15, + leadTimeDays: 7, + basePricePerCover: 295 + } + + const handleEditMenu = (menu: Menu) => { + setEditingMenu(menu) + setShowForm(true) + } + + const handleNewMenu = () => { + setEditingMenu(defaultMenu) + setShowForm(true) + } + + const handleSaveMenu = async (menu: Menu) => { + try { + if (editingMenu?.id) { + // Update existing menu + const response = await fetch('/api/menus', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(menu) + }) + if (!response.ok) throw new Error('Failed to update menu') + } else { + // Add new menu + const newMenu = { + ...menu, + id: `menu-${Date.now()}` + } + const response = await fetch('/api/menus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newMenu) + }) + if (!response.ok) throw new Error('Failed to create menu') + } + fetchMenus() + setShowForm(false) + setEditingMenu(null) + } catch (err) { + setError('Der skete en fejl ved gem af menu') + console.error(err) + } + } + + const handleDeleteMenu = async (menuId: string) => { + if (confirm('Er du sikker på at du vil slette denne menu?')) { + try { + const response = await fetch('/api/menus', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: menuId }) + }) + if (!response.ok) throw new Error('Failed to delete menu') + fetchMenus() + } catch (err) { + setError('Der skete en fejl ved sletning af menu') + console.error(err) + } + } + } + + return ( +
+
+
+
+

Menu Administration

+ +
+ + {error && ( +
+ {error} + setError(null)}> + + Luk + + + +
+ )} + + {loading ? ( +
+
+

Indlæser menuer...

+
+ ) : showForm ? ( + { + setShowForm(false) + setEditingMenu(null) + }} + /> + ) : ( +
+ {menus.map(menu => ( +
+
+
+
+ +
+
+

{menu.title}

+

{menu.description}

+
+ {menu.tags.map(tag => ( + + {tag} + + ))} +
+
+
+
+ + +
+
+
+ ))} +
+ )} +
+
+
+ ) +} + +function MenuForm({ menu, onSave, onCancel }: { + menu: Menu, + onSave: (menu: Menu) => void, + onCancel: () => void +}) { + const [formData, setFormData] = useState(menu) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave(formData) + } + + return ( +
+
+
+
+ + setFormData({...formData, title: e.target.value})} + className="w-full rounded-lg border border-primary-200 px-3 py-2" + required + /> +
+
+ + setFormData({...formData, image: e.target.value})} + className="w-full rounded-lg border border-primary-200 px-3 py-2" + /> +
+
+ +
+ +