Initialize repo and add session changes (2025-09-08)
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
65
.env.example
Normal 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
10
.gitattributes
vendored
Normal 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
45
.github/workflows/lint.yml
vendored
Normal 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
50
.github/workflows/tests.yml
vendored
Normal 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
27
.gitignore
vendored
Normal 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
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
resources/js/components/ui/*
|
||||
resources/views/mail/*
|
||||
19
.prettierrc
Normal file
19
.prettierrc
Normal 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
60
AGENTS.md
Normal 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
189
PWA_Wireframes.txt
Normal 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: 2–5
|
||||
|
||||
BUTTONS
|
||||
[ 📷 Los geht’s ] (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 ❤️“
|
||||
--------------------------------------------------
|
||||
97
app/Console/Commands/AddDummyTenantUser.php
Normal file
97
app/Console/Commands/AddDummyTenantUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
85
app/Console/Commands/AttachDemoEvent.php
Normal file
85
app/Console/Commands/AttachDemoEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
app/Console/Commands/BackfillThumbnails.php
Normal file
53
app/Console/Commands/BackfillThumbnails.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
192
app/Filament/Resources/EmotionResource.php
Normal file
192
app/Filament/Resources/EmotionResource.php
Normal 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];
|
||||
}
|
||||
}
|
||||
89
app/Filament/Resources/EventResource.php
Normal file
89
app/Filament/Resources/EventResource.php
Normal 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;
|
||||
}
|
||||
73
app/Filament/Resources/EventTypeResource.php
Normal file
73
app/Filament/Resources/EventTypeResource.php
Normal 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;
|
||||
}
|
||||
71
app/Filament/Resources/LegalPageResource.php
Normal file
71
app/Filament/Resources/LegalPageResource.php
Normal 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;
|
||||
}
|
||||
90
app/Filament/Resources/PhotoResource.php
Normal file
90
app/Filament/Resources/PhotoResource.php
Normal 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;
|
||||
}
|
||||
203
app/Filament/Resources/TaskResource.php
Normal file
203
app/Filament/Resources/TaskResource.php
Normal 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];
|
||||
}
|
||||
}
|
||||
72
app/Filament/Resources/TenantResource.php
Normal file
72
app/Filament/Resources/TenantResource.php
Normal 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;
|
||||
}
|
||||
40
app/Filament/Widgets/EventsActiveToday.php
Normal file
40
app/Filament/Widgets/EventsActiveToday.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Filament/Widgets/PlatformStatsWidget.php
Normal file
31
app/Filament/Widgets/PlatformStatsWidget.php
Normal 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"),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Filament/Widgets/RecentPhotosTable.php
Normal file
39
app/Filament/Widgets/RecentPhotosTable.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Filament/Widgets/TopTenantsByUploads.php
Normal file
33
app/Filament/Widgets/TopTenantsByUploads.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
app/Filament/Widgets/UploadsPerDayChart.php
Normal file
52
app/Filament/Widgets/UploadsPerDayChart.php
Normal 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';
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/Admin/QrController.php
Normal file
21
app/Http/Controllers/Admin/QrController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
||||
218
app/Http/Controllers/Api/EventPublicController.php
Normal file
218
app/Http/Controllers/Api/EventPublicController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
207
app/Http/Controllers/Api/TenantController.php
Normal file
207
app/Http/Controllers/Api/TenantController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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')]);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal 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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
24
app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
39
app/Http/Controllers/Settings/PasswordController.php
Normal file
39
app/Http/Controllers/Settings/PasswordController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Settings/ProfileController.php
Normal file
63
app/Http/Controllers/Settings/ProfileController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
32
app/Http/Middleware/ApiTokenAuth.php
Normal file
32
app/Http/Middleware/ApiTokenAuth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Http/Middleware/HandleAppearance.php
Normal file
23
app/Http/Middleware/HandleAppearance.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
app/Http/Middleware/HandleInertiaRequests.php
Normal file
51
app/Http/Middleware/HandleInertiaRequests.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Http/Requests/Auth/LoginRequest.php
Normal file
88
app/Http/Requests/Auth/LoginRequest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal 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
27
app/Models/Emotion.php
Normal 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
29
app/Models/Event.php
Normal 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
27
app/Models/EventType.php
Normal 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
18
app/Models/LegalPage.php
Normal 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
22
app/Models/Photo.php
Normal 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
27
app/Models/Task.php
Normal 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
21
app/Models/Tenant.php
Normal 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
54
app/Models/User.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
63
app/Providers/Filament/SuperAdminPanelProvider.php
Normal file
63
app/Providers/Filament/SuperAdminPanelProvider.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Support/ImageHelper.php
Normal file
69
app/Support/ImageHelper.php
Normal 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
18
artisan
Normal 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
27
bootstrap/app.php
Normal 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
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
6
bootstrap/providers.php
Normal file
6
bootstrap/providers.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\SuperAdminPanelProvider::class,
|
||||
];
|
||||
21
components.json
Normal file
21
components.json
Normal 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
85
composer.json
Normal 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
10830
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
composer.phar
Normal file
BIN
composer.phar
Normal file
Binary file not shown.
126
config/app.php
Normal file
126
config/app.php
Normal 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
115
config/auth.php
Normal 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
108
config/cache.php
Normal 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
182
config/database.php
Normal 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
80
config/filesystems.php
Normal 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
55
config/inertia.php
Normal 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
132
config/logging.php
Normal 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
116
config/mail.php
Normal 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
112
config/queue.php
Normal 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
38
config/services.php
Normal 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
217
config/session.php
Normal 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
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
29
database/migrations/2025_09_01_100300_create_tasks_table.php
Normal file
29
database/migrations/2025_09_01_100300_create_tasks_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
31
database/seeders/DatabaseSeeder.php
Normal file
31
database/seeders/DatabaseSeeder.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
database/seeders/DemoAchievementsSeeder.php
Normal file
73
database/seeders/DemoAchievementsSeeder.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
database/seeders/DemoEventSeeder.php
Normal file
24
database/seeders/DemoEventSeeder.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
44
database/seeders/EmotionsSeeder.php
Normal file
44
database/seeders/EmotionsSeeder.php
Normal 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],
|
||||
], []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
244
database/seeders/EventTasksSeeder.php
Normal file
244
database/seeders/EventTasksSeeder.php
Normal 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', 'Close‑up 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 Nose‑Touch.', '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.'],
|
||||
['High‑Five‑Kette', 'Gebt euch reihum High‑Fives.', 'High‑Five Chain', 'High‑fives 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!'],
|
||||
['Spiegel‑Lacher', 'Spiegelt exakt die Mimik.', 'Mirror Laugh', 'Mirror each other’s laugh.'],
|
||||
['Tanzender Eingang', 'Tanzend ins Bild laufen.', 'Dancing Entrance', 'Dance into the frame.'],
|
||||
['Konfetti‑Grinsen', '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.'],
|
||||
['Freunde‑Huddle', 'Köpfe zusammen, Grinsen groß.', 'Friends Huddle', 'Heads together, big grin.'],
|
||||
['Back‑to‑Back', 'Rücken an Rücken posieren.', 'Back to Back', 'Pose back‑to‑back.'],
|
||||
['V‑Zeichen', 'Peace‑Zeichen kreativ einsetzen.', 'Peace Sign', 'Use peace sign creatively.'],
|
||||
['Luftkuss', 'Kuss in die Kamera werfen.', 'Air Kiss', 'Blow a kiss to the camera.'],
|
||||
['Emoji‑Gesichter', 'Stellt Emojis nach.', 'Emoji Faces', 'Act out your favorite emojis.'],
|
||||
['Mini‑Choreo', '3‑Schritt‑Tanz, dann Foto.', 'Mini Choreo', '3‑step dance then photo.'],
|
||||
['Hut‑Tausch', '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.'],
|
||||
['Oh‑yeah‑Pose', 'Begeisterungs‑Pose mit Fäusten.', 'Oh‑Yeah Pose', 'Fists up “oh‑yeah” pose.'],
|
||||
['Händeschütteln', 'Übertriebenes Händeschütteln.', 'Epic Handshake', 'Over‑the‑top handshake.'],
|
||||
],
|
||||
'Rührung' => [
|
||||
['Berührender Blick', 'Schaut euch sanft in die Augen.', 'Tender Look', 'Gently look into each other’s eyes.'],
|
||||
['Freudentränen', 'Ein Taschentuchmoment (authentisch).', 'Happy Tears', 'Capture a tissue moment.'],
|
||||
['Hände nah', 'Nahaufnahme ineinanderliegender Hände.', 'Hands Close', 'Close‑up of intertwined hands.'],
|
||||
['Dankesumarmung', 'Umarmt jemanden, dem ihr danken wollt.', 'Thank‑You 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 war’s“ 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 it’s 1960 – b/w mood.'],
|
||||
['Tanz der Eltern', 'Imitiert den Tanz eurer Eltern.', 'Parents’ Dance', 'Imitate your parents’ dance.'],
|
||||
['Vintage‑Pose', 'Hände gefaltet, altmodische Pose.', 'Vintage Pose', 'Folded hands, old‑school 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/Handy‑Galerie 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', 'Retro‑Accessoires improvisieren.', 'Retro Props', 'Improvise retro props.'],
|
||||
['Polaroid‑Look', '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', 'Old‑school phone gesture.'],
|
||||
['Hut & Handschuhe', 'Elegante 20er‑Jahre Geste.', 'Hat & Gloves', 'Elegant 1920s gesture.'],
|
||||
['Nostalgie‑Umarmung', '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' => [
|
||||
['Konfetti‑Boom', '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.', 'Pop‑in Guest', 'A guest pops into frame.'],
|
||||
['Geschenk‑Reveal', '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.'],
|
||||
['Flip‑Pose', '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.'],
|
||||
['Schnips‑Moment', '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.'],
|
||||
['Mini‑Schreck', '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 you’re proud of.'],
|
||||
['Team‑Applaus', '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', 'Impro‑Trophä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.', 'Line‑Up', 'Line up like a team photo.'],
|
||||
['Partnerpose', 'Zwei nebeneinander – stolz.', 'Partner Pose', 'Two side by side – proud.'],
|
||||
['Familienstolz', 'Familien‑Stolzpose mit Lächeln.', 'Family Pride', 'Family pride pose.'],
|
||||
['Freundesstolz', 'Freundes‑Stolzpose, 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.'],
|
||||
['Conga‑Linie', 'Conga‑Schlange – Foto von vorn.', 'Conga Line', 'Conga line – shot from front.'],
|
||||
['Telefonkette', 'Flüstern nacheinander – letzter ruft’s.', 'Whisper Chain', 'Whisper chain – last says it.'],
|
||||
['Namensschrift', 'Formt den Namen des Paars.', 'Name Shape', 'Shape the couple’s 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', 'Zweier‑Teams spiegeln Posen.', 'Mirror in Pairs', 'Pairs mirror poses.'],
|
||||
['Huckepack', 'Jemand trägt jemanden – sicher!', 'Piggyback', 'Piggyback (safely!).'],
|
||||
['Doppel‑Cheers', 'Zweier‑“Cheers!” in Serie.', 'Double Cheers', 'Pairs do a quick cheers.'],
|
||||
['Timing‑Klatscher', '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.'],
|
||||
['Handshake‑Kette', '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 hand‑off.'],
|
||||
['Teamruf', 'Alle rufen denselben Teamruf.', 'Team Chant', 'Shout a team chant.'],
|
||||
['Wir‑Welle', '“Wir!”‑Ruf, Hände hoch.', 'We‑Wave', '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.'],
|
||||
['Danke‑Geste', 'Dankbare Geste mit Blick zur Kamera.', 'Grateful Gesture', 'Grateful gesture to camera.'],
|
||||
['Nahaufnahme Augen', 'Fokus auf die Augen.', 'Eyes Close‑Up', '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.'],
|
||||
['Schulter‑Touch', '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 (shouldn’t), 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
database/seeders/EventTypesSeeder.php
Normal file
23
database/seeders/EventTypesSeeder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
database/seeders/SuperAdminSeeder.php
Normal file
22
database/seeders/SuperAdminSeeder.php
Normal 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
Reference in New Issue
Block a user