Initialize repo and add session changes (2025-09-08)

This commit is contained in:
Auto Commit
2025-09-08 14:03:43 +02:00
commit 44ab0a534b
327 changed files with 40952 additions and 0 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

10
.gitattributes vendored Normal file
View File

@@ -0,0 +1,10 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore

45
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'

50
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm ci
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Build Assets
run: npm run build
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Tests
run: ./vendor/bin/phpunit

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
/public/build
/public/hot
/public/storage
/resources/js/actions
/resources/js/routes
/resources/js/wayfinder
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
resources/js/components/ui/*
resources/views/mail/*

19
.prettierrc Normal file
View File

@@ -0,0 +1,19 @@
{
"semi": true,
"singleQuote": true,
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 150,
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn"],
"tailwindStylesheet": "resources/css/app.css",
"tabWidth": 4,
"overrides": [
{
"files": "**/*.yml",
"options": {
"tabWidth": 2
}
}
]
}

60
AGENTS.md Normal file
View File

@@ -0,0 +1,60 @@
# AGENTS.md — Agent Guidance for Event Photo Platform
This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.4, Filament 4, React/Vite PWA). This document defines how AI agents should operate in this repo: roles, permissions, safety rules, and standard workflows. It is the single source of truth for agent behavior. Per-agent details live in docs/agents/.
## Purpose & Scope
- Provide clear guardrails and playbooks so agents can assist safely with code, docs, DevOps and project hygiene.
- Applies to the whole repo unless a component has an explicit per-agent policy in docs/agents/.
## Roles
- Codegen Agent — implements and edits application code, tests and documentation within scoped tasks. See docs/agents/codegen.md.
- Ops Agent — automates tasks around CI/CD, releases, issue hygiene, and repo maintenance. See docs/agents/ops.md.
- (Optional) Docs Agent — maintains documentation quality; follow Codegen Agent rules with writing focus.
## Global Policies
- Secrets & Credentials:
- Never commit secrets. The local file gogs.ini (token=…) is ignored via .gitignore and must not be printed into logs.
- ENV values in .env are sensitive; do not commit them or echo to build logs.
- Data Protection:
- Respect GDPR. Do not introduce PII logging. Legal content (Impressum, Privacy, AGB) is managed via Legal Pages resource.
- Safety & Access:
- Prefer least privilege. Do not alter production data or infrastructure from code without explicit human approval.
- When uncertain about a destructive operation, open a PR or create an Issue with a proposal.
- Source of Truth:
- Keep this AGENTS.md authoritative. If per-agent docs diverge, update this file and link the rationale.
## Tools & Permissions
- Languages/Frameworks: PHP 8.3 (Laravel 12), JS/TS (React/Vite/Tailwind), Filament 4.
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev).
- Git Hosting: Gogs at http://192.168.78.2:10880 (token found locally in gogs.ini, never printed or committed).
- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth).
## Repo Structure (high-level)
- docs/prp/ — split PRP (authoritative). Start at docs/prp/README.md.
- fotospiel_prp.md — legacy monolithic PRP (historical reference; do not edit).
- TODO.md — prioritized backlog; mirrored into Issues by Ops Agent.
## Standard Workflows
- Coding tasks (Codegen Agent):
1) Understand scope; update or create a minimal plan.
2) Edit code/docs via small, reviewable patches; keep changes focused.
3) Add/update tests if behavior changes.
4) Update docs when public surfaces change (PRP, docs/*).
5) Propose follow-ups as Issues if out of scope.
- Issue hygiene (Ops Agent):
- Import TODO.md tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance).
- Avoid duplicates by checking existing titles.
- Releases (Ops Agent):
- Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated.
## Constraints & Red-Lines
- Do not introduce tracking beyond what is documented (anonymous session_id only for guest PWA).
- Do not weaken auth, CSRF, CORS, or role checks.
- Do not expand data retention without updating Privacy policy.
## Change Management
- Propose updates to this file via PR. Include:
- Motivation and scope, affected agents, roll-out plan.
- Links to updated docs in docs/agents/.
## References

189
PWA_Wireframes.txt Normal file
View File

@@ -0,0 +1,189 @@
# 📱 PWA Wireframes (Markdown)
## Landingpage
--------------------------------------------------
HEADER
[ Willkommen bei Fotochallenge 🎉 ]
HAUPTBEREICH
Eingabe-Feld: [ QR-Code / Event-PIN ]
Button: [ ➡️ Event beitreten ]
FOOTER
(none)
--------------------------------------------------
## Profil-Setup
--------------------------------------------------
HEADER
[ Profil erstellen ]
HAUPTBEREICH
Eingabefeld: [ Dein Name ]
Option: Avatar wählen (Rund-Icons)
Button: [ ✅ Starten ]
--------------------------------------------------
## Startseite (Home)
--------------------------------------------------
HEADER
[ Hochzeit Anna & Tom 🎉 ] (Avatar rechts)
📸 Dein Fotospiel zur Hochzeit mach Aufgaben & teile Momente
INFO-BAR
👥 37 Gäste online | ✅ 142 Aufgaben gelöst
HAUPTBEREICH
[ 🎲 Aufgabe ziehen ] (großer Button, primär)
[ 😊 Wie fühlst du dich? ] (großer Button, sekundär)
(kleiner Link unterhalb:)
[ 📸 Einfach ein Foto machen ]
GALERIE / FEED
Filter: [ Neueste ] [ Beliebt ] [ Meine ]
Foto-Kacheln mit ❤️ Like-Icon
--------------------------------------------------
FOOTER-NAVI
🏠 Start 🎲 Aufgaben 🏆 Achievements 🖼️ Galerie
--------------------------------------------------
## Aufgaben-Picker
--------------------------------------------------
HEADER
[ 🎲 Aufgabe ziehen ] (X schließen)
HAUPTBEREICH
Option A: Zufallsaufgabe sofort anzeigen
Option B: Stimmung wählen (Emoji-Grid)
😊 Emotionen (Grid):
🤪 Albern 🕺 Energetisch 🤗 Herzlich
😎 Cool 🧑‍🤝‍🧑 Gemeinsam 🧠 Kreativ
😌 Ruhig 🎯 Mutig
Optional:
Slider "Energie-Level"
[ 🔋 niedrig | 🔋 mittel | 🔋 hoch ]
BUTTON
[ 👉 Aufgabe anzeigen ]
--------------------------------------------------
## Aufgaben-Detail (Karte)
--------------------------------------------------
HEADER
[ 🤪 Albern ] (X schließen)
AUFGABEN-KARTE
📸 Aufgabe:
"Mach ein Selfie, bei dem alle in die falsche Richtung schauen."
⏱️ Dauer: < 1 Min 👥 Gruppengröße: 25
BUTTONS
[ 📷 Los gehts ] (Foto machen/hochladen)
[ ↻ Neue Aufgabe ] (gleiche Stimmung)
[ 😊 Andere Stimmung wählen ] (zurück zum Picker)
--------------------------------------------------
## Kamera/Upload
--------------------------------------------------
HEADER
[ 📷 Foto aufnehmen ]
HAUPTBEREICH
[ Kamera öffnen ] oder [ Foto hochladen ]
Nach Upload:
✅ „Foto erfolgreich hochgeladen 🎉“
BUTTON
[ Galerie ansehen ]
--------------------------------------------------
## Galerie (Übersicht)
--------------------------------------------------
HEADER
[ 📸 Galerie ] (Filter oben)
FILTER
[ Neueste ] [ Beliebt ] [ Meine ]
FOTOS (Kachel- oder Feed-Ansicht)
[ ❤️ 12 ] Foto 1
[ ❤️ 5 ] Foto 2
[ ❤️ 21 ] Foto 3
...
Tap → öffnet Foto-Detail
--------------------------------------------------
## Foto-Detailansicht
--------------------------------------------------
HEADER
[ X Schließen ]
HAUPTBEREICH
📷 Foto im Vollbild
UNTER DEM FOTO
❤️ 23 Likes 🎉 4 Reaktionen
📸 Aufgabe: "Gruppenfoto mit mindestens 5 Personen"
👤 Hochgeladen von: „Lisa“
BUTTON
[ ❤️ Like ]
--------------------------------------------------
## Achievements / Erfolge
--------------------------------------------------
HEADER
[ 🏆 Meine Erfolge ]
HAUPTBEREICH
Badges:
🎊 Erstes Foto
🏅 5 Aufgaben erledigt
🔥 Beliebtestes Foto
👯 Gruppenfoto-König
Fortschritt-Balken: (xx / yy Aufgaben)
--------------------------------------------------
## Optionale Seiten
### Slideshow / Präsentation
--------------------------------------------------
HEADER
[ 📽️ Slideshow-Modus ]
HAUPTBEREICH
- Vollbild-Diashow der Galerie
- Automatischer Wechsel alle 5 Sek.
- Anzeige: Likes & Aufgabe
--------------------------------------------------
### Admin-Panel
--------------------------------------------------
HEADER
[ 👑 Admin-Panel ]
HAUPTBEREICH
- Fotos moderieren (löschen, hervorheben)
- Teilnehmer-Übersicht
--------------------------------------------------
### Event-Abschluss
--------------------------------------------------
HEADER
[ 🎉 Danke fürs Mitmachen! ]
HAUPTBEREICH
- Zusammenfassung: Anzahl Fotos, Aufgaben, Likes
- QR-Code oder Link zur Online-Galerie
- „Bis bald ❤️“
--------------------------------------------------

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Console\Attributes\AsCommand;
#[AsCommand(name: 'tenant:add-dummy')]
class AddDummyTenantUser extends Command
{
protected $signature = 'tenant:add-dummy
{--email=demo@example.com}
{--password=secret123!}
{--tenant="Demo Tenant"}
{--name="Demo Admin"}
{--update-password : Overwrite password if user already exists}
';
protected $description = 'Create a demo tenant and a tenant user with given credentials.';
public function handle(): int
{
$email = (string) $this->option('email');
$password = (string) $this->option('password');
$tenantName = (string) $this->option('tenant');
$userName = (string) $this->option('name');
// Pre-flight checks for common failures
if (! Schema::hasTable('users')) {
$this->error("Table 'users' does not exist. Run: php artisan migrate");
return self::FAILURE;
}
if (! Schema::hasTable('tenants')) {
$this->error("Table 'tenants' does not exist. Run: php artisan migrate");
return self::FAILURE;
}
DB::beginTransaction();
try {
// Create or fetch tenant
$slug = Str::slug($tenantName ?: 'demo-tenant');
/** @var Tenant $tenant */
$tenant = Tenant::query()->where('slug', $slug)->first();
if (! $tenant) {
$tenant = new Tenant();
$tenant->name = $tenantName;
$tenant->slug = $slug;
$tenant->domain = null;
$tenant->contact_name = $userName;
$tenant->contact_email = $email;
$tenant->contact_phone = null;
$tenant->event_credits_balance = 1;
$tenant->max_photos_per_event = 500;
$tenant->max_storage_mb = 1024;
$tenant->features = ['custom_branding' => false];
$tenant->save();
}
// Create or fetch user
/** @var User $user */
$user = User::query()->where('email', $email)->first();
$updatePassword = (bool) $this->option('update-password');
if (! $user) {
$user = new User();
if (Schema::hasColumn($user->getTable(), 'name')) $user->name = $userName;
$user->email = $email;
$user->password = Hash::make($password);
} else if ($updatePassword) {
$user->password = Hash::make($password);
}
if (Schema::hasColumn($user->getTable(), 'tenant_id')) {
$user->tenant_id = $tenant->id;
}
if (Schema::hasColumn($user->getTable(), 'role')) {
$user->role = 'tenant_admin';
}
$user->save();
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info('Dummy tenant user created/updated.');
$this->line('Tenant: '.$tenant->name.' (#'.$tenant->id.')');
$this->line('Email: '.$email);
$this->line('Password: '.$password);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'tenant:attach-demo-event')]
class AttachDemoEvent extends Command
{
protected $signature = 'tenant:attach-demo-event
{--tenant-email=demo@example.com : Email of tenant admin user to locate tenant}
{--tenant-slug= : Tenant slug (overrides tenant-email lookup)}
{--event-id= : Event ID}
{--event-slug= : Event slug}
';
protected $description = 'Attach an existing demo event to a tenant (by email or slug). Safe and idempotent.';
public function handle(): int
{
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
$this->error("Table 'events' does not exist. Run: php artisan migrate");
return self::FAILURE;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
return self::FAILURE;
}
$tenant = null;
if ($slug = $this->option('tenant-slug')) {
$tenant = Tenant::where('slug', $slug)->first();
}
if (! $tenant) {
$email = (string) $this->option('tenant-email');
/** @var User|null $user */
$user = User::where('email', $email)->first();
if ($user && $user->tenant_id) {
$tenant = Tenant::find($user->tenant_id);
}
}
if (! $tenant) {
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
return self::FAILURE;
}
$event = null;
if ($id = $this->option('event-id')) {
$event = Event::find($id);
} elseif ($slug = $this->option('event-slug')) {
$event = Event::where('slug', $slug)->first();
} else {
// Heuristics: first event without tenant, or a demo wedding by slug/name
$event = Event::whereNull('tenant_id')->first();
if (! $event) {
$event = Event::where('slug', 'like', '%demo%')->where('slug', 'like', '%wedding%')->first();
}
if (! $event) {
// Try JSON name contains "Demo" or "Wedding"
$event = Event::where('name', 'like', '%Demo%')->orWhere('name', 'like', '%Wedding%')->first();
}
}
if (! $event) {
$this->error('Event not found. Provide --event-id or --event-slug.');
return self::FAILURE;
}
// Idempotent update
if ((int) $event->tenant_id === (int) $tenant->id) {
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
return self::SUCCESS;
}
$event->tenant_id = $tenant->id;
$event->save();
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Support\ImageHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class BackfillThumbnails extends Command
{
protected $signature = 'media:backfill-thumbnails {--limit=500}';
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
public function handle(): int
{
$limit = (int) $this->option('limit');
$rows = DB::table('photos')
->select(['id','event_id','file_path','thumbnail_path'])
->orderBy('id')
->limit($limit)
->get();
$count = 0;
foreach ($rows as $r) {
$orig = $this->relativeFromUrl((string)$r->file_path);
$thumb = (string)($r->thumbnail_path ?? '');
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb
if (! $orig) continue;
$baseName = pathinfo($orig, PATHINFO_FILENAME);
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
if ($made) {
$url = Storage::url($made);
DB::table('photos')->where('id', $r->id)->update(['thumbnail_path' => $url, 'updated_at' => now()]);
$count++;
$this->line("Photo {$r->id}: thumb created");
}
}
$this->info("Done. Thumbnails generated: {$count}");
return self::SUCCESS;
}
private function relativeFromUrl(string $url): ?string
{
// Assume Storage::url maps to /storage/*
$p = parse_url($url, PHP_URL_PATH) ?? '';
if (str_starts_with($p, '/storage/')) {
return substr($p, strlen('/storage/'));
}
return null;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EmotionResource\Pages;
use App\Models\Emotion;
use Filament\Schemas\Schema as Schema;
use Filament\Schemas\Components as SC;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class EmotionResource extends Resource
{
protected static ?string $model = Emotion::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-face-smile';
protected static string|\UnitEnum|null $navigationGroup = 'Library';
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema->components([
SC\KeyValue::make('name')->label('Name (de/en)')->keyLabel('locale')->valueLabel('value')->default(['de' => '', 'en' => ''])->required(),
SC\TextInput::make('icon')->label('Icon/Emoji')->maxLength(50),
SC\TextInput::make('color')->maxLength(7)->helperText('#RRGGBB'),
SC\KeyValue::make('description')->label('Description (de/en)')->keyLabel('locale')->valueLabel('value'),
SC\TextInput::make('sort_order')->numeric()->default(0),
SC\Toggle::make('is_active')->default(true),
SC\Select::make('eventTypes')
->label('Event Types')
->multiple()
->searchable()
->preload()
->relationship('eventTypes', 'name'),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('icon'),
Tables\Columns\TextColumn::make('color'),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('sort_order')->sortable(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageEmotions::route('/'),
'import' => Pages\ImportEmotions::route('/import'),
];
}
}
namespace App\Filament\Resources\EmotionResource\Pages;
use App\Filament\Resources\EmotionResource;
use Filament\Resources\Pages\ManageRecords;
use Filament\Actions;
use Filament\Resources\Pages\Page;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class ManageEmotions extends ManageRecords
{
protected static string $resource = EmotionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('import')
->label('Import CSV')
->icon('heroicon-o-arrow-up-tray')
->url(EmotionResource::getUrl('import')),
Actions\Action::make('template')
->label('Download CSV Template')
->icon('heroicon-o-document-arrow-down')
->url(url('/super-admin/templates/emotions.csv')),
];
}
}
class ImportEmotions extends Page
{
protected static string $resource = EmotionResource::class;
protected string $view = 'filament.pages.blank';
protected ?string $heading = 'Import Emotions (CSV)';
public ?string $file = null;
protected function getFormSchema(): array
{
return [
Forms\Components\FileUpload::make('file')
->label('CSV file')
->acceptedFileTypes(['text/csv', 'text/plain'])
->directory('imports')
->required(),
];
}
protected function getFormActions(): array
{
return [
Forms\Components\Actions\Action::make('import')
->label('Import')
->action('doImport')
->color('primary')
];
}
public function doImport(): void
{
$state = $this->form->getState();
$path = $state['file'] ?? null;
if (! $path || ! Storage::disk('public')->exists($path)) {
Notification::make()->danger()->title('File not found')->send();
return;
}
$full = Storage::disk('public')->path($path);
[$ok, $fail] = $this->importEmotionsCsv($full);
Notification::make()->success()->title("Imported {$ok} rows")->body($fail ? "{$fail} failed" : null)->send();
}
private function importEmotionsCsv(string $file): array
{
$h = fopen($file, 'r');
if (! $h) return [0,0];
$ok = 0; $fail = 0;
// Expected headers: name_de,name_en,icon,color,description_de,description_en,sort_order,is_active,event_types
$headers = fgetcsv($h, 0, ',');
if (! $headers) return [0,0];
$map = array_flip($headers);
while (($row = fgetcsv($h, 0, ',')) !== false) {
try {
$nameDe = trim($row[$map['name_de']] ?? '');
$nameEn = trim($row[$map['name_en']] ?? '');
$name = $nameDe ?: $nameEn;
if ($name === '') { $fail++; continue; }
$data = [
'name' => ['de' => $nameDe, 'en' => $nameEn],
'icon' => $row[$map['icon']] ?? null,
'color' => $row[$map['color']] ?? null,
'description' => [
'de' => $row[$map['description_de']] ?? null,
'en' => $row[$map['description_en']] ?? null,
],
'sort_order' => (int)($row[$map['sort_order']] ?? 0),
'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0,
];
$id = DB::table('emotions')->insertGetId(array_merge($data, [
'created_at' => now(), 'updated_at' => now(),
]));
// Attach event types if provided (by slug list separated by '|')
$et = $row[$map['event_types']] ?? '';
if ($et) {
$slugs = array_filter(array_map('trim', explode('|', $et)));
if ($slugs) {
$ids = DB::table('event_types')->whereIn('slug', $slugs)->pluck('id')->all();
foreach ($ids as $eid) {
DB::table('emotion_event_type')->insertOrIgnore([
'emotion_id' => $id,
'event_type_id' => $eid,
]);
}
}
}
$ok++;
} catch (\Throwable $e) {
$fail++;
}
}
fclose($h);
return [$ok, $fail];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EventResource\Pages;
use App\Models\Event;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar';
protected static string|\UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('tenant_id')->label('Tenant')->sortable(),
Tables\Columns\TextColumn::make('name')->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('default_locale'),
Tables\Columns\TextColumn::make('join')->label('Join')
->getStateUsing(fn($record) => url("/e/{$record->slug}"))
->copyable()
->copyMessage('Join link copied'),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('toggle')
->label('Toggle Active')
->icon('heroicon-o-power')
->action(fn($record) => $record->update(['is_active' => ! (bool)$record->is_active])),
Tables\Actions\Action::make('join_link')
->label('Join Link / QR')
->icon('heroicon-o-qr-code')
->modalHeading('Event Join Link')
->modalSubmitActionLabel('Close')
->modalContent(fn($record) => view('filament.events.join-link', [
'link' => url("/e/{$record->slug}"),
])),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvents::route('/'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListEvents extends ListRecords
{
protected static string $resource = EventResource::class;
}
class ViewEvent extends ViewRecord
{
protected static string $resource = EventResource::class;
}
class EditEvent extends EditRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EventTypeResource\Pages;
use App\Models\EventType;
use Filament\Schemas\Schema as Schema;
use Filament\Schemas\Components as SC;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class EventTypeResource extends Resource
{
protected static ?string $model = EventType::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-swatch';
protected static string|\UnitEnum|null $navigationGroup = 'Library';
protected static ?int $navigationSort = 20;
public static function form(Schema $schema): Schema
{
return $schema->components([
SC\KeyValue::make('name')->label('Name (de/en)')->default(['de' => '', 'en' => ''])->required(),
SC\TextInput::make('slug')->required()->unique(ignoreRecord: true),
SC\TextInput::make('icon')->maxLength(64),
SC\KeyValue::make('settings')->label('Settings')->keyLabel('key')->valueLabel('value'),
SC\Select::make('emotions')
->label('Emotions')
->multiple()
->searchable()
->preload()
->relationship('emotions', 'name'),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('icon'),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageEventTypes::route('/'),
];
}
}
namespace App\Filament\Resources\EventTypeResource\Pages;
use App\Filament\Resources\EventTypeResource;
use Filament\Resources\Pages\ManageRecords;
class ManageEventTypes extends ManageRecords
{
protected static string $resource = EventTypeResource::class;
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\LegalPageResource\Pages;
use App\Models\LegalPage;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class LegalPageResource extends Resource
{
protected static ?string $model = LegalPage::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|\UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 40;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('slug')->badge(),
Tables\Columns\TextColumn::make('version')->badge(),
Tables\Columns\IconColumn::make('is_published')->boolean(),
Tables\Columns\TextColumn::make('effective_from')->date(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListLegalPages::route('/'),
'view' => Pages\ViewLegalPage::route('/{record}'),
'edit' => Pages\EditLegalPage::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\LegalPageResource\Pages;
use App\Filament\Resources\LegalPageResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListLegalPages extends ListRecords
{
protected static string $resource = LegalPageResource::class;
}
class ViewLegalPage extends ViewRecord
{
protected static string $resource = LegalPageResource::class;
}
class EditLegalPage extends EditRecord
{
protected static string $resource = LegalPageResource::class;
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PhotoResource\Pages;
use App\Models\Photo;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-photo';
protected static string|\UnitEnum|null $navigationGroup = 'Content';
protected static ?int $navigationSort = 30;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('thumbnail_path')->label('Thumb')->circular(),
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('event_id')->label('Event'),
Tables\Columns\TextColumn::make('likes_count')->label('Likes'),
Tables\Columns\IconColumn::make('is_featured')->boolean(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('feature')
->label('Feature')
->visible(fn($record) => ! (bool)$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => 1]))
->icon('heroicon-o-star'),
Tables\Actions\Action::make('unfeature')
->label('Unfeature')
->visible(fn($record) => (bool)$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => 0]))
->icon('heroicon-o-star'),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('feature')
->label('Feature selected')
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => 1])),
Tables\Actions\BulkAction::make('unfeature')
->label('Unfeature selected')
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => 0])),
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPhotos::route('/'),
'view' => Pages\ViewPhoto::route('/{record}'),
'edit' => Pages\EditPhoto::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\PhotoResource\Pages;
use App\Filament\Resources\PhotoResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListPhotos extends ListRecords
{
protected static string $resource = PhotoResource::class;
}
class ViewPhoto extends ViewRecord
{
protected static string $resource = PhotoResource::class;
}
class EditPhoto extends EditRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages;
use App\Models\Task;
use Filament\Schemas\Schema as Schema;
use Filament\Schemas\Components as SC;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string|\UnitEnum|null $navigationGroup = 'Library';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
{
return $schema->components([
SC\Select::make('emotion_id')->relationship('emotion', 'name')->required()->searchable()->preload(),
SC\Select::make('event_type_id')->relationship('eventType', 'name')->searchable()->preload()->label('Event Type (optional)'),
SC\KeyValue::make('title')->label('Title (de/en)')->default(['de' => '', 'en' => ''])->required(),
SC\KeyValue::make('description')->label('Description (de/en)'),
SC\Select::make('difficulty')->options([
'easy' => 'Easy',
'medium' => 'Medium',
'hard' => 'Hard',
])->default('easy'),
SC\KeyValue::make('example_text')->label('Example (de/en)'),
SC\TextInput::make('sort_order')->numeric()->default(0),
SC\Toggle::make('is_active')->default(true),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('emotion.name')->label('Emotion')->sortable()->searchable(),
Tables\Columns\TextColumn::make('eventType.name')->label('Event Type')->toggleable(),
Tables\Columns\TextColumn::make('title')->searchable()->limit(40),
Tables\Columns\TextColumn::make('difficulty')->badge(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('sort_order')->sortable(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageTasks::route('/'),
'import' => Pages\ImportTasks::route('/import'),
];
}
}
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Resources\Pages\ManageRecords;
use Filament\Actions;
use Filament\Resources\Pages\Page;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class ManageTasks extends ManageRecords
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('import')
->label('Import CSV')
->icon('heroicon-o-arrow-up-tray')
->url(TaskResource::getUrl('import')),
Actions\Action::make('template')
->label('Download CSV Template')
->icon('heroicon-o-document-arrow-down')
->url(url('/super-admin/templates/tasks.csv')),
];
}
}
class ImportTasks extends Page
{
protected static string $resource = TaskResource::class;
protected string $view = 'filament.pages.blank';
protected ?string $heading = 'Import Tasks (CSV)';
public ?string $file = null;
protected function getFormSchema(): array
{
return [
Forms\Components\FileUpload::make('file')
->label('CSV file')
->acceptedFileTypes(['text/csv', 'text/plain'])
->directory('imports')
->required(),
];
}
protected function getFormActions(): array
{
return [
Forms\Components\Actions\Action::make('import')
->label('Import')
->action('doImport')
->color('primary')
];
}
public function doImport(): void
{
$state = $this->form->getState();
$path = $state['file'] ?? null;
if (! $path || ! Storage::disk('public')->exists($path)) {
Notification::make()->danger()->title('File not found')->send();
return;
}
$full = Storage::disk('public')->path($path);
[$ok, $fail] = $this->importTasksCsv($full);
Notification::make()->success()->title("Imported {$ok} rows")->body($fail ? "${fail} failed" : null)->send();
}
private function importTasksCsv(string $file): array
{
$h = fopen($file, 'r');
if (! $h) return [0,0];
$ok = 0; $fail = 0;
// Expected headers: emotion_name,emotion_name_de,emotion_name_en,event_type_slug,title_de,title_en,description_de,description_en,difficulty,example_text_de,example_text_en,sort_order,is_active
$headers = fgetcsv($h, 0, ',');
if (! $headers) return [0,0];
$map = array_flip($headers);
while (($row = fgetcsv($h, 0, ',')) !== false) {
try {
$emotionName = trim($row[$map['emotion_name']] ?? '');
$emotionNameDe = trim($row[$map['emotion_name_de']] ?? '');
$emotionNameEn = trim($row[$map['emotion_name_en']] ?? '');
$emotionId = null;
if ($emotionName !== '') {
$emotionId = DB::table('emotions')->where('name', $emotionName)->value('id');
}
if (! $emotionId && $emotionNameDe !== '') {
$emotionId = DB::table('emotions')->where('name', 'like', '%"de":"'.str_replace('"','""',$emotionNameDe).'"%')->value('id');
}
if (! $emotionId && $emotionNameEn !== '') {
$emotionId = DB::table('emotions')->where('name', 'like', '%"en":"'.str_replace('"','""',$emotionNameEn).'"%')->value('id');
}
if (! $emotionId) { $fail++; continue; }
$eventTypeSlug = trim($row[$map['event_type_slug']] ?? '');
$eventTypeId = null;
if ($eventTypeSlug !== '') {
$eventTypeId = DB::table('event_types')->where('slug', $eventTypeSlug)->value('id');
}
$data = [
'emotion_id' => $emotionId,
'event_type_id' => $eventTypeId,
'title' => [
'de' => $row[$map['title_de']] ?? null,
'en' => $row[$map['title_en']] ?? null,
],
'description' => [
'de' => $row[$map['description_de']] ?? null,
'en' => $row[$map['description_en']] ?? null,
],
'difficulty' => $row[$map['difficulty']] ?? 'easy',
'example_text' => [
'de' => $row[$map['example_text_de']] ?? null,
'en' => $row[$map['example_text_en']] ?? null,
],
'sort_order' => (int)($row[$map['sort_order']] ?? 0),
'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('tasks')->insert($data);
$ok++;
} catch (\Throwable $e) {
$fail++;
}
}
fclose($h);
return [$ok, $fail];
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
use App\Models\Tenant;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office';
protected static string|\UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 10;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('event_credits_balance')->label('Credits'),
Tables\Columns\TextColumn::make('last_activity_at')->since()->label('Last activity'),
Tables\Columns\TextColumn::make('created_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenants::route('/'),
'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListTenants extends ListRecords
{
protected static string $resource = TenantResource::class;
}
class ViewTenant extends ViewRecord
{
protected static string $resource = TenantResource::class;
}
class EditTenant extends EditRecord
{
protected static string $resource = TenantResource::class;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class EventsActiveToday extends BaseWidget
{
protected static ?string $heading = 'Events active today';
protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table
{
$today = Carbon::today()->toDateString();
$query = DB::table('events as e')
->leftJoin('photos as p', function ($join) use ($today) {
$join->on('p.event_id', '=', 'e.id')
->whereRaw("date(p.created_at) = ?", [$today]);
})
->where('e.is_active', 1)
->whereDate('e.date', '<=', $today)
->selectRaw('e.id, e.slug, e.name, e.date, COUNT(p.id) as uploads_today')
->groupBy('e.id', 'e.slug', 'e.name', 'e.date')
->orderBy('e.date', 'desc')
->limit(10);
return $table
->query($query)
->columns([
Tables\Columns\TextColumn::make('id')->label('#')->width('60px'),
Tables\Columns\TextColumn::make('slug')->label('Slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\TextColumn::make('uploads_today')->label('Uploads today')->numeric(),
])
->paginated(false);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class PlatformStatsWidget extends BaseWidget
{
protected ?string $pollingInterval = '30s';
protected function getStats(): array
{
$now = Carbon::now();
$dayAgo = $now->copy()->subDay();
$tenants = (int) DB::table('tenants')->count();
$events = (int) DB::table('events')->count();
$photos = (int) DB::table('photos')->count();
$photos24h = (int) DB::table('photos')->where('created_at', '>=', $dayAgo)->count();
return [
Stat::make('Tenants', number_format($tenants)),
Stat::make('Events', number_format($events)),
Stat::make('Photos', number_format($photos))
->description("+{$photos24h} in last 24h"),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Facades\DB;
class RecentPhotosTable extends BaseWidget
{
protected static ?string $heading = 'Recent uploads';
protected int|string|array $columnSpan = 'full';
public function table(Tables\Table $table): Tables\Table
{
$query = DB::table('photos')->orderByDesc('created_at')->limit(10);
return $table
->query($query)
->columns([
Tables\Columns\ImageColumn::make('thumbnail_path')->label('Thumb')->circular(),
Tables\Columns\TextColumn::make('id')->label('#'),
Tables\Columns\TextColumn::make('event_id')->label('Event'),
Tables\Columns\TextColumn::make('likes_count')->label('Likes'),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->actions([
Tables\Actions\Action::make('feature')
->label('Feature')
->visible(fn($record) => ! (bool)($record->is_featured ?? 0))
->action(fn($record) => DB::table('photos')->where('id', $record->id)->update(['is_featured' => 1, 'updated_at' => now()])),
Tables\Actions\Action::make('unfeature')
->label('Unfeature')
->visible(fn($record) => (bool)($record->is_featured ?? 0))
->action(fn($record) => DB::table('photos')->where('id', $record->id)->update(['is_featured' => 0, 'updated_at' => now()])),
])
->paginated(false);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Facades\DB;
class TopTenantsByUploads extends BaseWidget
{
protected static ?string $heading = 'Top tenants by uploads';
protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table
{
$query = DB::table('photos as p')
->join('events as e', 'e.id', '=', 'p.event_id')
->join('tenants as t', 't.id', '=', 'e.tenant_id')
->selectRaw('t.id as tenant_id, t.name as tenant_name, COUNT(p.id) as uploads')
->groupBy('t.id', 't.name')
->orderByDesc('uploads')
->limit(5);
return $table
->query($query)
->columns([
Tables\Columns\TextColumn::make('tenant_id')->label('#')->width('60px'),
Tables\Columns\TextColumn::make('tenant_name')->label('Tenant')->searchable(),
Tables\Columns\TextColumn::make('uploads')->label('Uploads')->numeric(),
])
->paginated(false);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class UploadsPerDayChart extends ChartWidget
{
protected ?string $heading = 'Uploads (14 days)';
protected ?string $maxHeight = '220px';
protected ?string $pollingInterval = '60s';
protected function getData(): array
{
// Build last 14 days labels
$labels = [];
$start = Carbon::now()->startOfDay()->subDays(13);
for ($i = 0; $i < 14; $i++) {
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
}
// SQLite-friendly group by date
$rows = DB::table('photos')
->selectRaw("strftime('%Y-%m-%d', created_at) as d, count(*) as c")
->where('created_at', '>=', $start)
->groupBy('d')
->orderBy('d')
->get();
$map = collect($rows)->keyBy('d');
$data = array_map(fn ($d) => (int) ($map[$d]->c ?? 0), $labels);
return [
'labels' => $labels,
'datasets' => [
[
'label' => 'Uploads',
'data' => $data,
'borderColor' => '#f59e0b',
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
'tension' => 0.3,
],
],
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Admin;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrController extends BaseController
{
public function png(Request $request)
{
$data = (string) $request->query('data', '');
if ($data === '') {
return response('missing data', 400);
}
$png = QrCode::format('png')->size(300)->generate($data);
return response($png, 200, ['Content-Type' => 'image/png']);
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Http\Controllers\Api;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use App\Support\ImageHelper;
class EventPublicController extends BaseController
{
public function event(string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first([
'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at'
]);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
return response()->json([
'id' => $event->id,
'slug' => $event->slug,
'name' => $event->name,
'default_locale' => $event->default_locale,
'created_at' => $event->created_at,
'updated_at' => $event->updated_at,
])->header('Cache-Control', 'no-store');
}
public function stats(string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
$eventId = $event->id;
// Approximate online guests as distinct recent uploaders in last 10 minutes.
$tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
$onlineGuests = DB::table('photos')
->where('event_id', $eventId)
->where('created_at', '>=', $tenMinutesAgo)
->distinct('guest_name')
->count('guest_name');
// Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
$payload = [
'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved,
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode($payload));
$reqEtag = request()->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
return response()->json($payload)
->header('Cache-Control', 'no-store')
->header('ETag', $etag);
}
public function photos(Request $request, string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
$eventId = $event->id;
$since = $request->query('since');
$query = DB::table('photos')
->select(['id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at'])
->where('event_id', $eventId)
->orderByDesc('created_at')
->limit(60);
if ($since) {
$query->where('created_at', '>', $since);
}
$rows = $query->get();
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
$payload = [
'data' => $rows,
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode([$since, $latestPhotoAt]));
$reqEtag = request()->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
return response()->json($payload)
->header('Cache-Control', 'no-store')
->header('ETag', $etag)
->header('Last-Modified', (string)$latestPhotoAt);
}
public function photo(int $id)
{
$row = DB::table('photos')
->select(['id', 'event_id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at'])
->where('id', $id)
->first();
if (! $row) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404);
}
return response()->json($row)->header('Cache-Control', 'no-store');
}
public function like(Request $request, int $id)
{
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
if ($deviceId === '') {
$deviceId = 'anon';
}
$photo = DB::table('photos')->where('id', $id)->first(['id', 'event_id']);
if (! $photo) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404);
}
// Idempotent like per device
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
if ($exists) {
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => true, 'likes_count' => $count]);
}
DB::beginTransaction();
try {
DB::table('photo_likes')->insert([
'photo_id' => $id,
'guest_name' => $deviceId,
'ip_address' => 'device',
'created_at' => now(),
]);
DB::table('photos')->where('id', $id)->update([
'likes_count' => DB::raw('likes_count + 1'),
'updated_at' => now(),
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::warning('like failed', ['error' => $e->getMessage()]);
}
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function upload(Request $request, string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon';
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $event->id)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
}
$validated = $request->validate([
'photo' => ['required', 'image', 'max:6144'], // 6 MB
'emotion_id' => ['nullable', 'integer'],
'task_id' => ['nullable', 'integer'],
'guest_name' => ['nullable', 'string', 'max:255'],
]);
$file = $validated['photo'];
$path = Storage::disk('public')->putFile("events/{$event->id}/photos", $file);
$url = Storage::url($path);
// Generate thumbnail (JPEG) under photos/thumbs
$baseName = pathinfo($path, PATHINFO_FILENAME);
$thumbRel = "events/{$event->id}/photos/thumbs/{$baseName}_thumb.jpg";
$thumbPath = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbRel, 640, 82);
$thumbUrl = $thumbPath ? Storage::url($thumbPath) : $url;
$id = DB::table('photos')->insertGetId([
'event_id' => $event->id,
'emotion_id' => $validated['emotion_id'] ?? null,
'task_id' => $validated['task_id'] ?? null,
'guest_name' => $validated['guest_name'] ?? $deviceId,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
'likes_count' => 0,
'is_featured' => 0,
'metadata' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json([
'id' => $id,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
], 201);
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Event;
use App\Models\Photo;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class TenantController extends BaseController
{
public function login(Request $request)
{
$creds = $request->validate([
'email' => ['required','email'],
'password' => ['required','string'],
]);
if (! Auth::attempt($creds)) {
return response()->json(['error' => ['code' => 'invalid_credentials']], 401);
}
/** @var User $user */
$user = Auth::user();
// naive token (cache-based), expires in 8 hours
$token = Str::random(80);
Cache::put('api_token:'.$token, $user->id, now()->addHours(8));
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'tenant_id' => $user->tenant_id ?? null,
],
]);
}
public function me(Request $request)
{
$u = Auth::user();
return response()->json([
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'tenant_id' => $u->tenant_id ?? null,
]);
}
public function events()
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$q = Event::query();
if ($tenantId) {
$q->where('tenant_id', $tenantId);
}
return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id','name','slug','date','is_active'])]);
}
public function showEvent(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
return response()->json($ev->only(['id','name','slug','date','is_active','default_locale']));
}
public function storeEvent(Request $request)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$data = $request->validate([
'name' => ['required','string','max:255'],
'slug' => ['required','string','max:255'],
'date' => ['nullable','date'],
'is_active' => ['boolean'],
]);
$ev = new Event();
$ev->tenant_id = $tenantId ?? $ev->tenant_id;
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
$ev->slug = $data['slug'];
$ev->date = $data['date'] ?? null;
$ev->is_active = (bool)($data['is_active'] ?? true);
$ev->default_locale = 'de';
$ev->save();
return response()->json(['id' => $ev->id]);
}
public function updateEvent(Request $request, int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$data = $request->validate([
'name' => ['nullable','string','max:255'],
'slug' => ['nullable','string','max:255'],
'date' => ['nullable','date'],
'is_active' => ['nullable','boolean'],
]);
if (isset($data['name'])) $ev->name = ['de' => $data['name'], 'en' => $data['name']];
if (isset($data['slug'])) $ev->slug = $data['slug'];
if (array_key_exists('date', $data)) $ev->date = $data['date'];
if (array_key_exists('is_active', $data)) $ev->is_active = (bool)$data['is_active'];
$ev->save();
return response()->json(['ok' => true]);
}
public function toggleEvent(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$ev->is_active = ! (bool) $ev->is_active;
$ev->save();
return response()->json(['is_active' => (bool)$ev->is_active]);
}
public function eventStats(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$total = Photo::where('event_id', $id)->count();
$featured = Photo::where('event_id', $id)->where('is_featured', 1)->count();
$likes = Photo::where('event_id', $id)->sum('likes_count');
return response()->json([
'total' => (int)$total,
'featured' => (int)$featured,
'likes' => (int)$likes,
]);
}
public function createInvite(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$token = Str::random(32);
Cache::put('invite:'.$token, $ev->slug, now()->addDays(2));
$link = url('/e/'.$ev->slug).'?t='.$token;
return response()->json(['link' => $link]);
}
public function eventPhotos(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id','thumbnail_path','file_path','likes_count','is_featured','created_at']);
return response()->json(['data' => $rows]);
}
public function featurePhoto(int $photoId)
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->is_featured = 1; $p->save();
return response()->json(['ok' => true]);
}
public function unfeaturePhoto(int $photoId)
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->is_featured = 0; $p->save();
return response()->json(['ok' => true]);
}
public function deletePhoto(int $photoId)
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->delete();
return response()->json(['ok' => true]);
}
protected function authorizePhoto(Photo $p): void
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$event = Event::find($p->event_id);
if ($tenantId && $event && $event->tenant_id !== $tenantId) {
abort(403);
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Show the login page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/login', [
'canResetPassword' => Route::has('password.request'),
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password page.
*/
public function show(): Response
{
return Inertia::render('auth/confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Show the email verification prompt page.
*/
public function __invoke(Request $request): Response|RedirectResponse
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Show the password reset page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PasswordReset) {
return to_route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Show the password reset link request page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
Password::sendResetLink(
$request->only('email')
);
return back()->with('status', __('A reset link will be sent if the account exists.'));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Show the registration page.
*/
public function create(): Response
{
return Inertia::render('auth/register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
$request->fulfill();
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
/**
* Show the user's password settings page.
*/
public function edit(): Response
{
return Inertia::render('settings/password');
}
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile settings.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return to_route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class ApiTokenAuth
{
public function handle(Request $request, Closure $next)
{
$header = $request->header('Authorization', '');
if (! str_starts_with($header, 'Bearer ')) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
}
$token = substr($header, 7);
$userId = Cache::get('api_token:'.$token);
if (! $userId) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
}
$user = User::find($userId);
if (! $user) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
}
Auth::login($user); // for policies if needed
return $next($request);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
return [
...parent::share($request),
'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return $this->string('email')
->lower()
->append('|'.$this->ip())
->transliterate()
->value();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

27
app/Models/Emotion.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Emotion extends Model
{
protected $table = 'emotions';
protected $guarded = [];
protected $casts = [
'name' => 'array',
'description' => 'array',
];
public function eventTypes(): BelongsToMany
{
return $this->belongsToMany(EventType::class, 'emotion_event_type', 'emotion_id', 'event_type_id');
}
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
}

29
app/Models/Event.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Event extends Model
{
protected $table = 'events';
protected $guarded = [];
protected $casts = [
'date' => 'date',
'settings' => 'array',
'is_active' => 'boolean',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function photos(): HasMany
{
return $this->hasMany(Photo::class);
}
}

27
app/Models/EventType.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EventType extends Model
{
protected $table = 'event_types';
protected $guarded = [];
protected $casts = [
'name' => 'array',
'settings' => 'array',
];
public function emotions(): BelongsToMany
{
return $this->belongsToMany(Emotion::class, 'emotion_event_type', 'event_type_id', 'emotion_id');
}
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
}

18
app/Models/LegalPage.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LegalPage extends Model
{
protected $table = 'legal_pages';
protected $guarded = [];
protected $casts = [
'title' => 'array',
'body_markdown' => 'array',
'is_published' => 'boolean',
'effective_from' => 'datetime',
];
}

22
app/Models/Photo.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Photo extends Model
{
protected $table = 'photos';
protected $guarded = [];
protected $casts = [
'is_featured' => 'boolean',
'metadata' => 'array',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

27
app/Models/Task.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model
{
protected $table = 'tasks';
protected $guarded = [];
protected $casts = [
'title' => 'array',
'description' => 'array',
'example_text' => 'array',
];
public function emotion(): BelongsTo
{
return $this->belongsTo(Emotion::class);
}
public function eventType(): BelongsTo
{
return $this->belongsTo(EventType::class, 'event_type_id');
}
}

21
app/Models/Tenant.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $table = 'tenants';
protected $guarded = [];
protected $casts = [
'features' => 'array',
'last_activity_at' => 'datetime',
];
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
}

54
app/Models/User.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class SuperAdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('super-admin')
->path('super-admin')
->colors([
'primary' => Color::Amber,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
\App\Filament\Widgets\PlatformStatsWidget::class,
\App\Filament\Widgets\UploadsPerDayChart::class,
\App\Filament\Widgets\RecentPhotosTable::class,
\App\Filament\Widgets\TopTenantsByUploads::class,
\App\Filament\Widgets\EventsActiveToday::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Storage;
class ImageHelper
{
/**
* Create a JPEG thumbnail for a file stored on a given disk.
* Returns the relative path (on the same disk) or null on failure.
*/
public static function makeThumbnailOnDisk(string $disk, string $sourcePath, string $destPath, int $maxEdge = 600, int $quality = 82): ?string
{
try {
$fullSrc = Storage::disk($disk)->path($sourcePath);
if (! file_exists($fullSrc)) {
return null;
}
$data = @file_get_contents($fullSrc);
if ($data === false) {
return null;
}
// Prefer robust decode via GD from string (handles jpeg/png/webp if compiled)
$src = @imagecreatefromstring($data);
if (! $src) {
return null;
}
$w = imagesx($src);
$h = imagesy($src);
if ($w === 0 || $h === 0) {
imagedestroy($src);
return null;
}
$scale = min(1.0, $maxEdge / max($w, $h));
$tw = (int) max(1, round($w * $scale));
$th = (int) max(1, round($h * $scale));
$dst = imagecreatetruecolor($tw, $th);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $tw, $th, $w, $h);
// Ensure destination directory exists
$destDir = dirname($destPath);
Storage::disk($disk)->makeDirectory($destDir);
$fullDest = Storage::disk($disk)->path($destPath);
// Encode JPEG
@imagejpeg($dst, $fullDest, $quality);
imagedestroy($dst);
imagedestroy($src);
// Confirm file written
if (! file_exists($fullDest)) {
return null;
}
return $destPath;
} catch (\Throwable $e) {
// Silent failure; caller can fall back to original
return null;
}
}
}

18
artisan Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

27
bootstrap/app.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\SuperAdminPanelProvider::class,
];

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

85
composer.json Normal file
View File

@@ -0,0 +1,85 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/react-starter-kit",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"filament/filament": "~4.0",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"simplesoftwareio/simple-qrcode": "^4.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"dev:ssr": [
"npm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

10830
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

BIN
composer.phar Normal file

Binary file not shown.

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

108
config/cache.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
];

182
config/database.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

55
config/inertia.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Server Side Rendering
|--------------------------------------------------------------------------
|
| These options configures if and how Inertia uses Server Side Rendering
| to pre-render each initial request made to your application's pages
| so that server rendered HTML is delivered for the user's browser.
|
| See: https://inertiajs.com/server-side-rendering
|
*/
'ssr' => [
'enabled' => true,
'url' => 'http://127.0.0.1:13714',
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
],
/*
|--------------------------------------------------------------------------
| Testing
|--------------------------------------------------------------------------
|
| The values described here are used to locate Inertia components on the
| filesystem. For instance, when using `assertInertia`, the assertion
| attempts to locate the component as a file relative to the paths.
|
*/
'testing' => [
'ensure_pages_exist' => true,
'page_paths' => [
resource_path('js/pages'),
],
'page_extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
],
];

132
config/logging.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

116
config/mail.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

112
config/queue.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'resend' => [
'key' => env('RESEND_KEY'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "apc",
| "memcached", "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "apc", "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('event_types', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->string('slug')->unique();
$table->string('icon')->nullable();
$table->json('settings')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('event_types');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('emotions', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->string('icon', 50);
$table->string('color', 7);
$table->json('description')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('emotion_event_type', function (Blueprint $table) {
$table->unsignedBigInteger('emotion_id');
$table->unsignedBigInteger('event_type_id');
$table->primary(['emotion_id', 'event_type_id']);
});
}
public function down(): void
{
Schema::dropIfExists('emotion_event_type');
Schema::dropIfExists('emotions');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('description')->nullable();
$table->date('date');
$table->string('slug')->unique();
$table->json('settings')->nullable();
$table->unsignedBigInteger('event_type_id');
$table->boolean('is_active')->default(true);
$table->string('default_locale', 5)->default('de');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('events');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('emotion_id');
$table->unsignedBigInteger('event_type_id')->nullable();
$table->json('title');
$table->json('description');
$table->json('example_text')->nullable();
$table->enum('difficulty', ['easy','medium','hard'])->default('easy');
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('photos', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('event_id');
$table->unsignedBigInteger('emotion_id');
$table->unsignedBigInteger('task_id')->nullable();
$table->string('guest_name');
$table->string('file_path');
$table->string('thumbnail_path');
$table->integer('likes_count')->default(0);
$table->boolean('is_featured')->default(false);
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('photo_likes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('photo_id');
$table->string('guest_name');
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->unique(['photo_id','guest_name','ip_address']);
});
}
public function down(): void
{
Schema::dropIfExists('photo_likes');
Schema::dropIfExists('photos');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('legal_pages', function (Blueprint $table) {
$table->id();
$table->string('slug', 32);
$table->json('title');
$table->json('body_markdown');
$table->string('locale_fallback', 5)->default('de');
$table->integer('version')->default(1);
$table->timestamp('effective_from')->nullable();
$table->boolean('is_published')->default(false);
$table->timestamps();
$table->unique(['slug','version']);
});
}
public function down(): void
{
Schema::dropIfExists('legal_pages');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('super_admin')->after('password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('task_collections', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('description')->nullable();
$table->timestamps();
});
Schema::create('task_collection_task', function (Blueprint $table) {
$table->unsignedBigInteger('task_collection_id');
$table->unsignedBigInteger('task_id');
$table->primary(['task_collection_id','task_id']);
});
}
public function down(): void
{
Schema::dropIfExists('task_collection_task');
Schema::dropIfExists('task_collections');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('task_imports', function (Blueprint $table) {
$table->id();
$table->string('disk')->default('local');
$table->string('path');
$table->string('source_filename');
$table->enum('status', ['pending','processing','completed','failed'])->default('pending');
$table->unsignedInteger('total_rows')->default(0);
$table->unsignedInteger('imported_rows')->default(0);
$table->json('errors')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('task_imports');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->nullable()->unique();
$table->string('contact_name')->nullable();
$table->string('contact_email')->nullable();
$table->string('contact_phone')->nullable();
// Simple event-credit based monetization (MVP)
$table->integer('event_credits_balance')->default(1);
$table->timestamp('free_event_granted_at')->nullable();
// Limits & quotas
$table->integer('max_photos_per_event')->default(500);
$table->integer('max_storage_mb')->default(1024);
// Feature flags & misc
$table->json('features')->nullable();
$table->timestamp('last_activity_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tenants');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
if (! Schema::hasColumn('users', 'tenant_id')) {
$table->foreignId('tenant_id')->nullable()->after('id')
->constrained('tenants')->nullOnDelete();
}
if (! Schema::hasColumn('users', 'role')) {
$table->string('role', 32)->default('tenant_user')->after('password')->index();
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'tenant_id')) {
// Drop FK first if the driver supports it
try { $table->dropConstrainedForeignId('tenant_id'); } catch (\Throwable $e) {
try { $table->dropForeign(['tenant_id']); } catch (\Throwable $e2) {}
$table->dropColumn('tenant_id');
}
}
if (Schema::hasColumn('users', 'role')) {
$table->dropColumn('role');
}
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('events')) return;
Schema::table('events', function (Blueprint $table) {
if (! Schema::hasColumn('events', 'tenant_id')) {
$table->foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->nullOnDelete();
}
if (Schema::hasColumn('events', 'slug')) {
// Optional: ensure index exists
try { $table->index('slug'); } catch (\Throwable $e) {}
}
});
}
public function down(): void
{
if (! Schema::hasTable('events')) return;
Schema::table('events', function (Blueprint $table) {
if (Schema::hasColumn('events', 'tenant_id')) {
try { $table->dropConstrainedForeignId('tenant_id'); } catch (\Throwable $e) {
try { $table->dropForeign(['tenant_id']); } catch (\Throwable $e2) {}
$table->dropColumn('tenant_id');
}
}
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// Seed core demo data for frontend previews
$this->call([
EventTypesSeeder::class,
EmotionsSeeder::class,
DemoEventSeeder::class,
TasksSeeder::class,
DemoAchievementsSeeder::class,
]);
// Optional: demo user
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Models\{Event, Emotion, Task, Photo};
class DemoAchievementsSeeder extends Seeder
{
public function run(): void
{
$event = Event::where('slug', 'demo-wedding-2025')->first();
if (!$event) {
$event = Event::create([
'slug' => 'demo-wedding-2025',
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
'date' => now()->toDateString(),
'event_type_id' => null,
'is_active' => true,
'settings' => [],
'default_locale' => 'de',
]);
}
$emotions = Emotion::query()->take(6)->get();
if (Task::count() === 0 && $emotions->isNotEmpty()) {
foreach (range(1, 10) as $i) {
$emo = $emotions->random();
Task::create([
'title' => ['de' => "Aufgabe #$i", 'en' => "Task #$i"],
'description' => ['de' => 'Kurzbeschreibung', 'en' => 'Short description'],
'emotion_id' => $emo->id,
'is_active' => true,
]);
}
}
$tasks = Task::inRandomOrder()->take(10)->get();
if ($tasks->isEmpty()) {
return; // nothing to seed
}
// Simple placeholder PNG (100x100)
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBwYWludC5uZXQgNC4xLjM2qefiAAABVklEQVR4Xu3UQQrCMBRF0cK1J3YQyF5z6jYv3Q0W3gQ6b8IYc3ov2Jf6n8A0Yq1nG2mZfE8y2GQkAAAAAAAAAANhK0ZL8b3xP2m5b4+0O8S9I3o9b3r8CwV8u0aH3bX8wE4WqgX3m4v3zO2KJ6l4yT4xvCw0b1q2c2w8bqQO3vFf0u8wUo5L3a8b0n2l5yq9Kf4zvCw0f1q2s2w0bpQO7PFv0s8wco4b3a8b0n2k5yq9Kf4zvCw0f1q2s2w0bpQO7PFv0s8wYgAAAAAAAAAAAACw9wG0qN2b2l3cMQAAAABJRU5ErkJggg==');
$guests = ['Alex', 'Marie', 'Lukas', 'Lena', 'Tom', 'Sophie', 'Jonas', 'Mia'];
foreach (range(1, 24) as $i) {
$task = $tasks->random();
$fileName = 'photo_demo_'.Str::random(6).'.png';
$thumbName = 'thumb_demo_'.Str::random(6).'.png';
Storage::disk('public')->put('photos/'.$fileName, $png);
Storage::disk('public')->put('thumbnails/'.$thumbName, $png);
Photo::create([
'event_id' => $event->id,
'emotion_id' => $task->emotion_id,
'task_id' => $task->id,
'guest_name' => $guests[array_rand($guests)],
'file_path' => 'photos/'.$fileName,
'thumbnail_path' => 'thumbnails/'.$thumbName,
'likes_count' => rand(0, 7),
'metadata' => ['seeded' => true],
'created_at' => now()->subMinutes(rand(1, 180)),
'updated_at' => now(),
]);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\{Event, EventType};
class DemoEventSeeder extends Seeder
{
public function run(): void
{
$type = EventType::where('slug','wedding')->first();
if(!$type){ return; }
Event::updateOrCreate(['slug'=>'demo-wedding-2025'], [
'name' => ['de'=>'Demo Hochzeit 2025','en'=>'Demo Wedding 2025'],
'description' => ['de'=>'Demo-Event','en'=>'Demo event'],
'date' => now()->addMonths(3)->toDateString(),
'event_type_id' => $type->id,
'is_active' => true,
'settings' => [],
'default_locale' => 'de',
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Emotion;
use App\Models\EventType;
use Illuminate\Support\Facades\DB;
class EmotionsSeeder extends Seeder
{
public function run(): void
{
$emotionData = [
['name'=>['de'=>'Liebe','en'=>'Love'], 'icon'=>'💖', 'color'=>'#ff6b9d', 'description'=>['de'=>'Romantische Momente','en'=>'Romantic moments'], 'sort_order'=>1],
['name'=>['de'=>'Freude','en'=>'Joy'], 'icon'=>'😊', 'color'=>'#ffd93d', 'description'=>['de'=>'Fröhliche Augenblicke','en'=>'Happy moments'], 'sort_order'=>2],
['name'=>['de'=>'Rührung','en'=>'Touched'], 'icon'=>'🥹', 'color'=>'#6bcf7f', 'description'=>['de'=>'Berührende Szenen','en'=>'Touching scenes'], 'sort_order'=>3],
['name'=>['de'=>'Nostalgie','en'=>'Nostalgia'], 'icon'=>'🕰️', 'color'=>'#a78bfa', 'description'=>['de'=>'Erinnerungen','en'=>'Memories'], 'sort_order'=>4],
['name'=>['de'=>'Überraschung','en'=>'Surprise'], 'icon'=>'😲', 'color'=>'#fb7185', 'description'=>['de'=>'Unerwartete Momente','en'=>'Unexpected moments'], 'sort_order'=>5],
['name'=>['de'=>'Stolz','en'=>'Pride'], 'icon'=>'🏆', 'color'=>'#34d399', 'description'=>['de'=>'Triumphale Augenblicke','en'=>'Triumphal moments'], 'sort_order'=>6],
['name'=>['de'=>'Teamgeist','en'=>'Team Spirit'], 'icon'=>'🤝', 'color'=>'#38bdf8', 'description'=>['de'=>'Zusammenhalt','en'=>'Team bonding'], 'sort_order'=>7],
['name'=>['de'=>'Besinnlichkeit','en'=>'Contemplation'], 'icon'=>'🕯️', 'color'=>'#22c55e', 'description'=>['de'=>'Feierliche Stimmung','en'=>'Festive calm'], 'sort_order'=>8],
];
$typeIds = EventType::pluck('id','slug');
foreach ($emotionData as $e) {
$emotion = Emotion::updateOrCreate(['name->de' => $e['name']['de']], $e);
// Link to types
$links = ['wedding','birthday','corporate','christmas'];
if ($e['name']['de'] === 'Teamgeist') { $links = ['corporate']; }
if ($e['name']['de'] === 'Besinnlichkeit') { $links = ['christmas']; }
foreach ($links as $slug) {
if (isset($typeIds[$slug])) {
DB::table('emotion_event_type')->updateOrInsert([
'emotion_id' => $emotion->id,
'event_type_id' => $typeIds[$slug],
], []);
}
}
}
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use App\Models\{Emotion, Task};
class EventTasksSeeder extends Seeder
{
public function run(): void
{
// Define 20 themed prompts per emotion (DE/EN)
$catalog = [
'Liebe' => [
['Herzrahmen', 'Formt mit euren Händen ein Herz um das Paar.', 'Heart Frame', 'Make a heart with your hands around the couple.'],
['Erster Blick', 'Fangt den Moment des ersten Blicks ein.', 'First Look', 'Capture the first look moment.'],
['Kuss im Konfetti', 'Kussmoment mit Konfetti oder Blütenregen.', 'Kiss in Confetti', 'A kiss with confetti or petals.'],
['Ringmoment', 'Nahaufnahme von Händen und Ringen.', 'Rings & Hands', 'Closeup of hands and rings.'],
['Schleierwind', 'Der Schleier weht fangt Bewegung ein.', 'Veil in the Wind', 'Catch the veil in motion.'],
['Liebesbrief', 'Lest euch eine kurze Botschaft vor.', 'Love Note', 'Read a short love note.'],
['Umarmung von hinten', 'Eine Person umarmt die andere von hinten.', 'Back Hug', 'Back hug pose.'],
['Sonnenuntergang', 'Silhouette im goldenen Licht.', 'Sunset Silhouette', 'Silhouette in golden light.'],
['Nasenstupser', 'Forehead oder NoseTouch.', 'Forehead Touch', 'Forehead or nose touch.'],
['Händchenhalten', 'Hände greifen sich Fokus auf Gefühl.', 'Holding Hands', 'Hands meeting focus on feeling.'],
['Tanz im Freien', 'Kurzer Tanzschritt unter freiem Himmel.', 'Dance Outside', 'A quick dance step outdoors.'],
['Lächeln mit Augen', '„Smize“ lächelt nur mit den Augen.', 'Smize', 'Smile with your eyes.'],
['Gegensätze ziehen an', 'Stellt eure Unterschiede liebvoll dar.', 'Opposites Attract', 'Show your differences playfully.'],
['Hinter dem Schleier', 'Kuss hinter dem Schleier.', 'Behind the Veil', 'Kiss behind the veil.'],
['Hand aufs Herz', 'Hände auf Herz echter Moment.', 'Hand on Heart', 'Hands on heart genuine moment.'],
['Namen schreiben', 'Schreibt Namen in die Luft (Lichtspur).', 'Name in Light', 'Write names in the air (light trail).'],
['Blick zurück', 'Geht weg und schaut zurück zur Kamera.', 'Look Back', 'Walk away, look back to camera.'],
['Gemeinsames Lachen', 'Bringt euch zum Lachen und klick.', 'Shared Laugh', 'Make each other laugh and snap.'],
['Spiegelmoment', 'Euer Spiegelbild kreativ einbauen.', 'Mirror Moment', 'Use your reflection creatively.'],
['Handkuss', 'Ein klassischer Handkuss.', 'Hand Kiss', 'A classic hand kiss.'],
],
'Freude' => [
['Lachwelle', 'Reihe bildet nacheinander eine Lachwelle.', 'Laugh Wave', 'Create a laugh wave one by one.'],
['HighFiveKette', 'Gebt euch reihum HighFives.', 'HighFive Chain', 'Highfives around the group.'],
['Freudensprung', 'Springt gleichzeitig in die Luft.', 'Jump of Joy', 'Jump together.'],
['Fotobomb', 'Huscht freundlich ins Bild.', 'Photobomb', 'Sneak into the shot (friendly!).'],
['Überraschtes Gesicht', 'Überraschte Mimik Hände hoch!', 'Surprised Face', 'Surprised faces hands up!'],
['SpiegelLacher', 'Spiegelt exakt die Mimik.', 'Mirror Laugh', 'Mirror each others laugh.'],
['Tanzender Eingang', 'Tanzend ins Bild laufen.', 'Dancing Entrance', 'Dance into the frame.'],
['KonfettiGrinsen', 'Konfetti werfen & lachen.', 'Confetti Grin', 'Throw confetti & grin.'],
['Witz erzählen', 'Schneller Witz Klick beim Lachen.', 'Tell a Joke', 'Tell a joke, snap at the laugh.'],
['FreundeHuddle', 'Köpfe zusammen, Grinsen groß.', 'Friends Huddle', 'Heads together, big grin.'],
['BacktoBack', 'Rücken an Rücken posieren.', 'Back to Back', 'Pose backtoback.'],
['VZeichen', 'PeaceZeichen kreativ einsetzen.', 'Peace Sign', 'Use peace sign creatively.'],
['Luftkuss', 'Kuss in die Kamera werfen.', 'Air Kiss', 'Blow a kiss to the camera.'],
['EmojiGesichter', 'Stellt Emojis nach.', 'Emoji Faces', 'Act out your favorite emojis.'],
['MiniChoreo', '3SchrittTanz, dann Foto.', 'Mini Choreo', '3step dance then photo.'],
['HutTausch', 'Accessoires tauschen & posen.', 'Hat Swap', 'Swap props and pose.'],
['Cheers!', 'Gläser/Tassen anstoßen.', 'Cheers!', 'Clink glasses/cups.'],
['Zungenakrobatik', 'Zunge raus Spaßpose.', 'Silly Tongue', 'Tongue out silly pose.'],
['OhyeahPose', 'BegeisterungsPose mit Fäusten.', 'OhYeah Pose', 'Fists up “ohyeah” pose.'],
['Händeschütteln', 'Übertriebenes Händeschütteln.', 'Epic Handshake', 'Overthetop handshake.'],
],
'Rührung' => [
['Berührender Blick', 'Schaut euch sanft in die Augen.', 'Tender Look', 'Gently look into each others eyes.'],
['Freudentränen', 'Ein Taschentuchmoment (authentisch).', 'Happy Tears', 'Capture a tissue moment.'],
['Hände nah', 'Nahaufnahme ineinanderliegender Hände.', 'Hands Close', 'Closeup of intertwined hands.'],
['Dankesumarmung', 'Umarmt jemanden, dem ihr danken wollt.', 'ThankYou Hug', 'Hug someone you want to thank.'],
['Erinnerungsstück', 'Haltet ein Erinnerungsstück in die Kamera.', 'Keepsake', 'Show a meaningful keepsake.'],
['Flüstern', 'Flüstert ein Kompliment ins Ohr.', 'Whisper Compliment', 'Whisper a compliment.'],
['Ruhemoment', 'Schließt kurz die Augen, atmet ein.', 'Quiet Moment', 'Close eyes, breathe in.'],
['Schulterblick', 'Kopf auf Schulter Geborgenheit.', 'Head on Shoulder', 'Head on shoulder warmth.'],
['Verlobungsstory', 'Zeigt „so wars“ mit Gesten.', 'Engagement Story', 'Act out “how it happened”.'],
['Geschenk öffnen', 'Kleines Geschenk öffnen Reaktion.', 'Open a Gift', 'Open a small gift reaction.'],
['Erste Erinnerung', 'Teilt eine kurze erste Erinnerung.', 'First Memory', 'Share a first memory.'],
['Wunsch ans Paar', 'Flüstert einen Wunsch fürs Paar.', 'Wish for Couple', 'Whisper a wish for the couple.'],
['Nahporträt', 'Sehr nahes Porträt sanftes Licht.', 'Close Portrait', 'Very close portrait, soft light.'],
['Schritt für Schritt', 'Langsam aufeinander zugehen.', 'Step by Step', 'Walk slowly towards each other.'],
['Gedankenpose', 'Denkt an etwas Schönes Klick.', 'Thoughtful Pose', 'Think of something lovely click.'],
['Ringkuss', 'Kuss auf die Hand mit Ring.', 'Ring Kiss', 'Kiss the hand with the ring.'],
['Stille Freude', 'Leises Lächeln, geschlossene Augen.', 'Quiet Joy', 'Soft smile, eyes closed.'],
['Vertrauter Halt', 'Arm einhaken, nah zusammen.', 'Linked Arms', 'Link arms, stand close.'],
['„Danke“ zeigen', 'Schreibt „Danke“ mit Händen/Karte.', 'Show “Thank You”', 'Show “Thank You” with hands/card.'],
['Ein Wort', 'Sagt gleichzeitig 1 Wort über den anderen.', 'One Word', 'Say one word about the other.'],
],
'Nostalgie' => [
['Altes Foto nachstellen', 'Stellt ein altes Familienfoto nach.', 'Recreate Old Photo', 'Recreate an old family photo.'],
['Schwarzweiß', 'Tut so, als wäre es 1960 s/w Look.', 'Black & White', 'Pretend its 1960 b/w mood.'],
['Tanz der Eltern', 'Imitiert den Tanz eurer Eltern.', 'Parents Dance', 'Imitate your parents dance.'],
['VintagePose', 'Hände gefaltet, altmodische Pose.', 'Vintage Pose', 'Folded hands, oldschool pose.'],
['Familienerbstück', 'Haltet ein Erbstück in die Kamera.', 'Heirloom', 'Show a family heirloom.'],
['Erstes Treffen', 'Stellt euer erstes Treffen nach.', 'First Meeting', 'Reenact your first meeting.'],
['Album aufschlagen', 'Album/HandyGalerie zeigen.', 'Open Album', 'Show an album/gallery.'],
['Zeitreise', 'Pose wie in eurer Lieblingsdekade.', 'Time Travel', 'Pose from your favorite decade.'],
['Brief an Zukunft', 'Haltet “Brief an uns” in Kamera.', 'Letter to Future', 'Hold “letter to us” to camera.'],
['Requisiten retro', 'RetroAccessoires improvisieren.', 'Retro Props', 'Improvise retro props.'],
['PolaroidLook', 'Haltet einen Rahmen wie Polaroid.', 'Polaroid Frame', 'Pose with a “polaroid” frame.'],
['Kinderfoto', 'Haltet ein Kinderfoto gleiche Pose.', 'Childhood Photo', 'Hold a childhood photo same pose.'],
['Alte Geste', 'Eine frühere Gewohnheit nachstellen.', 'Old Habit', 'Act out an old habit.'],
['Telefon von früher', 'Imitierte Telefongesten von früher.', 'Old Phone', 'Oldschool phone gesture.'],
['Hut & Handschuhe', 'Elegante 20erJahre Geste.', 'Hat & Gloves', 'Elegant 1920s gesture.'],
['NostalgieUmarmung', 'Langsame, lange Umarmung.', 'Nostalgic Hug', 'Slow, long hug.'],
['Erster Tanzschritt', 'Stellt den ersten Tanzschritt nach.', 'First Step', 'Reenact first dance step.'],
['Alte Kamera', 'Tut so, als würdet ihr analog knipsen.', 'Old Camera', 'Pretend to shoot on film.'],
['Kinoplakat', 'Stellt ein altes Filmplakat nach.', 'Movie Poster', 'Recreate a vintage movie poster.'],
['Handschrift', 'Schreibt Vornamen mit schöner Schrift.', 'Handwriting', 'Write names in neat script.'],
],
'Überraschung' => [
['KonfettiBoom', 'Unerwarteter Konfettischwall Klick!', 'Confetti Boom', 'Surprise confetti snap!'],
['Hinter dem Rücken', 'Zeigt etwas hinter dem Rücken vor.', 'Behind the Back', 'Reveal something from behind your back.'],
['Gäste tauchen auf', 'Neue Person springt ins Bild.', 'Popin Guest', 'A guest pops into frame.'],
['GeschenkReveal', 'Kleines Geschenk enthüllen.', 'Gift Reveal', 'Reveal a small gift.'],
['Plötzlicher Tanz', 'Unerwarteter Tanzmove!', 'Sudden Dance', 'Do a surprise dance move.'],
['Hand vor Mund', '„Oh!“Geste mit Augen groß.', 'Oh! Gesture', '“Oh!” gesture, big eyes.'],
['Wechsel der Plätze', 'Springt schnell die Plätze.', 'Switch Places', 'Quickly switch places.'],
['Falsche Richtung', 'Schaut alle woanders hin.', 'Look Away', 'Everyone looks elsewhere.'],
['FlipPose', 'Posenwechsel auf Kommando.', 'Flip Pose', 'Flip pose on cue.'],
['Plötzliches Lachen', 'Lachen aus dem Nichts.', 'Sudden Laugh', 'Burst into laughter.'],
['Versteckspiel', 'Versteckt euch hinter Deko.', 'Hide & Seek', 'Hide behind decor.'],
['Schattenspiel', 'Schatten an der Wand formen.', 'Shadow Play', 'Make shadows on the wall.'],
['SchnipsMoment', 'Schnippt und friert ein.', 'Snap & Freeze', 'Snap fingers and freeze.'],
['Unerwarteter Hut', 'Plötzlich Hut/Schal tauschen.', 'Surprise Hat', 'Swap hats/scarves.'],
['Zaubertrick', 'Kleiner „Magic“Trick.', 'Magic Trick', 'A tiny “magic” trick.'],
['Drehen & Stopp', 'Dreht euch Stopp Klick.', 'Spin & Stop', 'Spin and stop snap.'],
['Enger Zoom', 'Kamera ganz nah ran.', 'Tight Zoom', 'Get very close to the camera.'],
['Falscher Start', 'Tut so, als wärt ihr schon fertig.', 'False Start', 'Pretend you finished already.'],
['MiniSchreck', 'Erschreckt euch spielerisch.', 'Play Scare', 'Play a tiny scare.'],
['Jubelruf', 'Unerwarteter Jubel Arme hoch.', 'Cheer Burst', 'Sudden cheer hands up.'],
],
'Stolz' => [
['Siegerpose', 'Stolze Siegerpose mit Pokalgesten.', 'Victory Pose', 'Proud victory pose.'],
['Daumen hoch', 'Großer Daumen hoch zur Kamera.', 'Thumbs Up', 'Big thumbs up to camera.'],
['„Das haben wir geschafft“', 'Zeigt auf euch und lächelt stolz.', 'We Did It', 'Point at yourselves, proud smile.'],
['Orden anheften', 'Tut so, als würdet ihr Orden heften.', 'Pin a Medal', 'Pretend to pin a medal.'],
['Haltung zeigen', 'Aufrecht stehen, Brust raus.', 'Stand Tall', 'Stand upright, chest out.'],
['Meisterstück', 'Zeigt ein Ergebnis, auf das ihr stolz seid.', 'Masterpiece', 'Show a result youre proud of.'],
['TeamApplaus', 'Applaus füreinander, dann in Kamera.', 'Applaud Each Other', 'Applaud each other, then camera.'],
['Hand aufs Herz', 'Stolzer Blick, Hand aufs Herz.', 'Proud Heart', 'Hand on heart, proud look.'],
['Stehende Welle', 'Alle nacheinander aufstehen Klick.', 'Standing Wave', 'Stand up one by one snap.'],
['Schultern klopfen', 'Klopft euch freundlich auf die Schulter.', 'Pat on Back', 'Pat each other on the back.'],
['Banner hoch', 'Hebt ein Schild „Yeah!“ hoch.', 'Banner Up', 'Hold up a “Yeah!” sign.'],
['Heldenblick', 'Blick in die Ferne, Kinn hoch.', 'Hero Look', 'Look into distance, chin up.'],
['Trophäe improvisiert', 'ImproTrophäe in die Höhe.', 'Impro Trophy', 'Raise an improvised trophy.'],
['Da ist die Kamera', 'Selbstsicher direkt in die Linse.', 'Own the Lens', 'Confidently into the lens.'],
['Spitzenleistung', 'Zeigt die “Nummer Eins”Geste.', 'Number One', 'Show a “number one” gesture.'],
['Aufstellung', 'Stellt euch wie ein Teamfoto auf.', 'LineUp', 'Line up like a team photo.'],
['Partnerpose', 'Zwei nebeneinander stolz.', 'Partner Pose', 'Two side by side proud.'],
['Familienstolz', 'FamilienStolzpose mit Lächeln.', 'Family Pride', 'Family pride pose.'],
['Freundesstolz', 'FreundesStolzpose, Arm in Arm.', 'Friends Pride', 'Arms around pride.'],
['Applaus fürs Paar', 'Applaudiert dem Paar in Kamera.', 'Applause for Couple', 'Applaud the couple to camera.'],
],
'Teamgeist' => [
['Handkreis', 'Hände in der Mitte stapeln Go!', 'Hand Circle', 'Hands stacked in the middle.'],
['CongaLinie', 'CongaSchlange Foto von vorn.', 'Conga Line', 'Conga line shot from front.'],
['Telefonkette', 'Flüstern nacheinander letzter rufts.', 'Whisper Chain', 'Whisper chain last says it.'],
['Namensschrift', 'Formt den Namen des Paars.', 'Name Shape', 'Shape the couples name.'],
['Brückenbau', 'Bildet eine Menschenbrücke.', 'Human Bridge', 'Form a human bridge.'],
['Wimpelzug', 'Zieht imaginäre Wimpel hoch.', 'Pennant Pull', 'Pull up imaginary pennants.'],
['Schulter an Schulter', 'Dicht zusammen Teamblick.', 'Shoulder to Shoulder', 'Shoulder to shoulder.'],
['Wellenlauf', 'Eine Person läuft durch Spalier.', 'Guard of Honor', 'Run through a guard of honor.'],
['Spiegeln zu zweit', 'ZweierTeams spiegeln Posen.', 'Mirror in Pairs', 'Pairs mirror poses.'],
['Huckepack', 'Jemand trägt jemanden sicher!', 'Piggyback', 'Piggyback (safely!).'],
['DoppelCheers', 'Zweier“Cheers!” in Serie.', 'Double Cheers', 'Pairs do a quick cheers.'],
['TimingKlatscher', 'Alle klatschen gleichzeitig.', 'Sync Clap', 'Clap in sync.'],
['Zugseil', 'Tut so, als würdet ihr ein Seil ziehen.', 'Tug the Rope', 'Pretend tug of war.'],
['HandshakeKette', 'Kette aus Handschlägen.', 'Handshake Chain', 'Chain of handshakes.'],
['Formationsfoto', 'Stellt eine Form (Kreis/Herz).', 'Formation', 'Form a circle/heart.'],
['Staffelstab', 'Imitiere StaffelstabÜbergabe.', 'Relay Baton', 'Relay baton handoff.'],
['Teamruf', 'Alle rufen denselben Teamruf.', 'Team Chant', 'Shout a team chant.'],
['WirWelle', '“Wir!”Ruf, Hände hoch.', 'WeWave', 'Shout “We!” hands up.'],
['Gassenlauf', 'Gasse bilden jemand läuft durch.', 'Lane Run', 'Form a lane someone runs.'],
['Schulterreihe', 'Schultern fassen, Reihe bilden.', 'Linked Row', 'Link shoulders, form a row.'],
],
'Besinnlichkeit' => [
['Kerzenlicht', 'Portrait im Kerzenlicht (vorsichtig).', 'Candle Light', 'Portrait by candlelight (careful).'],
['Ruhepause', 'Augen schließen, tief atmen.', 'Quiet Pause', 'Close eyes, breathe deeply.'],
['Hand aufs Herz', 'Innere Ruhe Blick nach innen.', 'Hand on Heart', 'Look inward calm.'],
['Leises Lächeln', 'Ganz sanftes Lächeln.', 'Soft Smile', 'Very gentle smile.'],
['Fensterlicht', 'Seitliches Fensterlicht nutzen.', 'Window Light', 'Use side window light.'],
['Lesemoment', 'Jemand liest leise vor.', 'Reading Moment', 'Someone reads softly.'],
['Gebet/Wunsch', 'Ein stiller Wunsch oder Gebet.', 'Silent Wish', 'A quiet wish or prayer.'],
['DankeGeste', 'Dankbare Geste mit Blick zur Kamera.', 'Grateful Gesture', 'Grateful gesture to camera.'],
['Nahaufnahme Augen', 'Fokus auf die Augen.', 'Eyes CloseUp', 'Focus on the eyes.'],
['Hand in Hand', 'Langsame Bewegung der Hände.', 'Hands Moving', 'Slow hand movement.'],
['Anlehnen', 'Lehnt euch sanft aneinander.', 'Lean Gently', 'Lean gently together.'],
['Still stehen', '30 Sekunden ganz ruhig stehen.', 'Stand Still', 'Stand very still.'],
['Licht & Schatten', 'Sanftes Spiel aus Licht/Schatten.', 'Light & Shadow', 'Soft light/shadow play.'],
['Aufrichtigkeit', 'Direkter, ruhiger Blick.', 'Sincere Look', 'Direct, calm look.'],
['Hauch von Lächeln', 'Nur ein Hauch subtil.', 'Hint of Smile', 'Just a hint subtle.'],
['Hände im Schoß', 'Entspannte Hände im Schoß.', 'Hands in Lap', 'Hands resting in lap.'],
['Stiller Kreis', 'Kleiner Kreis, Köpfe zusammen.', 'Quiet Circle', 'Small circle, heads together.'],
['SchulterTouch', 'Kurzer Schulterkontakt, Ruhe.', 'Shoulder Touch', 'Brief shoulder touch.'],
['Atem zählen', 'Zählt 3 Atemzüge gemeinsam.', 'Count Breaths', 'Count 3 breaths together.'],
['Augen schließen', 'Alle Augen schließen Klick.', 'Eyes Closed', 'All close eyes click.'],
],
];
// Difficulty rotation
$difficulties = ['easy','easy','medium','easy','medium','hard'];
foreach (Emotion::all() as $emotion) {
$name = is_array($emotion->name) ? ($emotion->name['de'] ?? array_values($emotion->name)[0]) : (string) $emotion->name;
$list = $catalog[$name] ?? null;
if (!$list) continue; // skip unknown emotion labels
$created = 0; $order = 1;
foreach ($list as $i => $row) {
[$deTitle, $deDesc, $enTitle, $enDesc] = $row;
// Avoid duplicates: check same DE title within this emotion
$exists = Task::where('emotion_id', $emotion->id)
->where('title->de', $deTitle)
->exists();
if ($exists) { $order++; continue; }
Task::create([
'emotion_id' => $emotion->id,
'event_type_id' => null,
'title' => ['de' => $deTitle, 'en' => $enTitle],
'description' => ['de' => $deDesc, 'en' => $enDesc],
'example_text' => ['de' => null, 'en' => null],
'difficulty' => $difficulties[$i % count($difficulties)],
'sort_order' => $order++,
'is_active' => true,
]);
$created++;
}
// Ensure at least 20: if list shorter (shouldnt), cycle again with suffix
$i = 0;
while ($created < 20 && $i < count($list)) {
[$deTitle, $deDesc, $enTitle, $enDesc] = $list[$i];
$suffix = ' #' . ($created + 1);
Task::create([
'emotion_id' => $emotion->id,
'event_type_id' => null,
'title' => ['de' => $deTitle.$suffix, 'en' => $enTitle.$suffix],
'description' => ['de' => $deDesc, 'en' => $enDesc],
'example_text' => ['de' => null, 'en' => null],
'difficulty' => $difficulties[$created % count($difficulties)],
'sort_order' => $order++,
'is_active' => true,
]);
$created++; $i++;
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\EventType;
class EventTypesSeeder extends Seeder
{
public function run(): void
{
$types = [
['name' => ['de'=>'Hochzeit','en'=>'Wedding'], 'slug'=>'wedding', 'icon'=>'💍'],
['name' => ['de'=>'Weihnachten','en'=>'Christmas'], 'slug'=>'christmas', 'icon'=>'🎄'],
['name' => ['de'=>'Geburtstag','en'=>'Birthday'], 'slug'=>'birthday', 'icon'=>'🎂'],
['name' => ['de'=>'Firma','en'=>'Corporate'], 'slug'=>'corporate', 'icon'=>'🏢'],
];
foreach ($types as $t) {
EventType::updateOrCreate(['slug'=>$t['slug']], $t);
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
class SuperAdminSeeder extends Seeder
{
public function run(): void
{
$email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
User::updateOrCreate(['email'=>$email], [
'name' => 'Super Admin',
'password' => Hash::make($password),
'role' => 'super_admin',
]);
}
}

Some files were not shown because too many files have changed in this diff Show More