commit a63515cfc6be75e8cfba8e1bdb4f87c34408c108 Author: ayrisdev Date: Tue Jul 14 00:01:48 2026 +0300 Initial commit: Animexe Laravel platform Co-Authored-By: Claude Opus 4.8 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -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 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/.env.example @@ -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}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71b1ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..6ff9005 --- /dev/null +++ b/.htaccess @@ -0,0 +1,4 @@ + + RewriteEngine On + RewriteRule ^(.*)$ public/$1 [L] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Console/Commands/CloseTribunals.php b/app/Console/Commands/CloseTribunals.php new file mode 100644 index 0000000..df8c441 --- /dev/null +++ b/app/Console/Commands/CloseTribunals.php @@ -0,0 +1,55 @@ +where('closes_at', '<=', now()) + ->get(); + + if ($expired->isEmpty()) { + $this->info('Kapatılacak mahkeme yok.'); + return 0; + } + + foreach ($expired as $tribunal) { + $sides = $tribunal->allSides(); + $counts = []; + foreach (array_keys($sides) as $side) { + $counts[$side] = TribunalVote::where('tribunal_id', $tribunal->id) + ->where('side', $side) + ->count(); + } + + arsort($counts); + $topSide = array_key_first($counts); + $topCount = $counts[$topSide]; + $allEqual = count(array_unique(array_values($counts))) === 1 && array_sum($counts) > 0; + + $verdict = null; + if (!$allEqual && $topCount > 0) { + $verdict = $sides[$topSide] ?? $topSide; + } + + $tribunal->update([ + 'status' => 'closed', + 'verdict' => $verdict, + ]); + + $this->line("Kapatıldı: #{$tribunal->id} — Karar: " . ($verdict ?? 'Beraberlik')); + } + + $this->info("{$expired->count()} mahkeme kapatıldı."); + return 0; + } +} diff --git a/app/Console/Commands/CreateCrossImportJobs.php b/app/Console/Commands/CreateCrossImportJobs.php new file mode 100644 index 0000000..c4b46f5 --- /dev/null +++ b/app/Console/Commands/CreateCrossImportJobs.php @@ -0,0 +1,132 @@ +option('force'); + $only = $this->option('only'); + $dryRun = $this->option('dry-run'); + + $this->info("Animexe Çapraz Import Job Oluşturucu"); + $this->info("===================================="); + + // Tüm yayınlanan animeleri job durumlarıyla al + $animes = Anime::where('is_published', true) + ->with('importJobs') + ->get(); + + $this->info("Toplam yayınlanan anime: {$animes->count()}"); + + $aniziumCreated = 0; + $animecixCreated = 0; + $skipped = 0; + $requeued = 0; + + foreach ($animes as $anime) { + $jobs = $anime->importJobs; + $aniziumJobs = $jobs->where('source', 'anizium'); + $animecixJobs = $jobs->where('source', 'animecix'); + + $hasAniziumDone = $aniziumJobs->where('status', 'done')->isNotEmpty(); + $hasAnimecixDone = $animecixJobs->where('status', 'done')->isNotEmpty(); + $hasAniziumAny = $aniziumJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty(); + $hasAnimecixAny = $animecixJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty(); + + // ── Re-queue: ikisi de done olan animeleri yeniden işlet ────────── + if ($force && $hasAniziumDone && $hasAnimecixDone) { + if (!$dryRun) { + // En son done Anizium job'unu yeniden pending yap + $aj = $aniziumJobs->where('status', 'done')->sortByDesc('id')->first(); + if ($aj) { + $aj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']); + } + // En son done AnimeCix job'unu yeniden pending yap + $cj = $animecixJobs->where('status', 'done')->sortByDesc('id')->first(); + if ($cj) { + $cj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']); + } + $requeued++; + } else { + $this->line("[DRY] Re-queue: {$anime->title} (her iki kaynak mevcut)"); + } + continue; + } + + // ── Anizium job oluştur ─────────────────────────────────────────── + if ((!$only || $only === 'anizium') && !$hasAniziumAny) { + // Anizium watch_id'yi bilmiyoruz — Python reimport_all.py bu görevi üstlenir. + // Buradan sadece anime_id'yi atayarak "ihtiyaç listesi" oluşturabiliriz; + // watch_id olmadan bot job'ı işleyemez. Bu yüzden sadece logluyoruz. + $this->warn("[SKIP-ANİZİUM] {$anime->title} (watch_id bilinmiyor — reimport_all.py kullan)"); + $skipped++; + } + + // ── AnimeCix job oluştur ────────────────────────────────────────── + if ((!$only || $only === 'animecix') && !$hasAnimecixAny) { + // animecix_title_id'yi bilmiyoruz — daemon.py bunu otomatik keşfeder. + $this->warn("[SKIP-ANİMECİX] {$anime->title} (title_id bilinmiyor — daemon.py kullan)"); + $skipped++; + } + + // ── Anizium'u olan ama AnimeCix'i olmayan: Anizium job'larından watch_id var ── + // ─ (bu zaten mevcut) ─ + + // ── AnimeCix'i olan ama Anizium'u olmayan: watch_id bilinmiyorsa skip ── + if ((!$only || $only === 'anizium') && $hasAnimecixDone && !$hasAniziumAny) { + // AnimeCix'te var ama Anizium'da yok — reimport_all.py Anizium katalogunda arayacak + $this->line("[ANİZİUM-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — reimport_all.py işleyecek"); + } + + // ── Anizium'u olan ama AnimeCix'i olmayan: daemon.py katalog taramasında otomatik bulur ── + if ((!$only || $only === 'animecix') && $hasAniziumDone && !$hasAnimecixAny) { + $this->line("[ANİMECİX-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — daemon.py bulacak"); + } + } + + // ── Var olan Anizium done job'larını yeniden pending yap (--force) ─── + if ($force && !$dryRun) { + $this->info("Re-queue tamamlandı: {$requeued} anime"); + } + + $this->newLine(); + $this->info("Özet:"); + $this->info(" Anizium job oluşturuldu : {$aniziumCreated}"); + $this->info(" AnimeCix job oluşturuldu: {$animecixCreated}"); + $this->info(" Yeniden kuyruğa alındı : {$requeued}"); + $this->info(" Atlandı (watch_id yok) : {$skipped}"); + $this->newLine(); + $this->info("Tüm anime + kaynak eşleştirmesi için: python reimport_all.py"); + + return 0; + } +} diff --git a/app/Console/Commands/FetchAniListImages.php b/app/Console/Commands/FetchAniListImages.php new file mode 100644 index 0000000..51dc0c1 --- /dev/null +++ b/app/Console/Commands/FetchAniListImages.php @@ -0,0 +1,67 @@ +option('id')) { + $query->where('id', $id); + } elseif (!$this->option('all')) { + // Varsayılan: eksik resmi olanlar + $query->where(function ($q) { + $q->whereNull('cover_image')->orWhere('cover_image', '') + ->orWhereNull('banner_image')->orWhere('banner_image', ''); + }); + } + + $limit = (int) $this->option('limit'); + $animes = $query->limit($limit)->get(); + + if ($animes->isEmpty()) { + $this->info('Eksik resim bulunamadı.'); + return; + } + + $this->info("İşlenecek: {$animes->count()} anime"); + $bar = $this->output->createProgressBar($animes->count()); + $bar->start(); + + $ok = $skip = $fail = 0; + + foreach ($animes as $anime) { + try { + $updated = $service->fillImages($anime); + $updated ? $ok++ : $skip++; + } catch (\Throwable $e) { + $fail++; + $this->newLine(); + $this->warn("#{$anime->id} {$anime->title}: {$e->getMessage()}"); + } + + $bar->advance(); + usleep(500_000); // AniList rate limit: 90 req/dakika + } + + $bar->finish(); + $this->newLine(2); + $this->info("Bitti — güncellendi: {$ok}, değişmedi: {$skip}, hata: {$fail}"); + } +} diff --git a/app/Console/Commands/FetchMalIds.php b/app/Console/Commands/FetchMalIds.php new file mode 100644 index 0000000..cf0ae3f --- /dev/null +++ b/app/Console/Commands/FetchMalIds.php @@ -0,0 +1,62 @@ +option('force')) { + $query->whereNull('mal_id'); + } + + $animes = $query->get(); + $this->info("Processing {$animes->count()} anime(s)…"); + $bar = $this->output->createProgressBar($animes->count()); + $bar->start(); + + $found = 0; + foreach ($animes as $anime) { + try { + if (!$anime->mal_id || $this->option('force')) { + $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type); + if ($malId) { + $anime->update(['mal_id' => $malId]); + $found++; + } + usleep(400_000); // rate limit + } + + // Fill season chain + if ($anime->mal_id) { + $chain = $jikan->fetchSeasonMalIds($anime->mal_id); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (!$season->mal_id && isset($chain[$i])) { + $season->update(['mal_id' => $chain[$i]]); + } + } + } + } catch (\Throwable $e) { + $this->newLine(); + $this->warn(" ⚠ [{$anime->title}]: {$e->getMessage()}"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Done. {$found} new MAL ID(s) fetched."); + return 0; + } +} diff --git a/app/Console/Commands/FillAnimeMeta.php b/app/Console/Commands/FillAnimeMeta.php new file mode 100644 index 0000000..f2a9a1a --- /dev/null +++ b/app/Console/Commands/FillAnimeMeta.php @@ -0,0 +1,174 @@ +isConfigured()) { + $this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'); + return self::FAILURE; + } + + $limit = (int) $this->option('limit'); + $animeId = $this->option('anime'); + $force = $this->option('force'); + $dry = $this->option('dry-run'); + + // İşlenecek animeleri belirle + if ($animeId) { + $animes = Anime::where('id', $animeId)->with('genres')->get(); + } else { + $query = Anime::with('genres'); + + if (!$force) { + $query->where(function ($q) { + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ->orWhereNull('studio')->orWhere('studio', '') + ->orWhereNull('type')->orWhere('type', '') + ->orWhereNull('status')->orWhere('status', ''); + })->orDoesntHave('genres'); + } + + $query->orderBy('id'); + if ($limit > 0) $query->limit($limit); + $animes = $query->get(); + } + + if ($animes->isEmpty()) { + $this->info('İşlenecek anime bulunamadı (tüm alanlar dolu).'); + Log::channel('daily')->info('[FillAnimeMeta] İşlenecek anime yok.'); + return self::SUCCESS; + } + + $this->info("Toplam {$animes->count()} anime işlenecek" . ($dry ? ' (dry-run)' : '') . '...'); + Log::channel('daily')->info("[FillAnimeMeta] Başladı. {$animes->count()} anime, limit={$limit}, force=" . ($force ? 'evet' : 'hayır')); + + $done = 0; + $skipped = 0; + $failed = 0; + + foreach ($animes as $anime) { + $missing = $this->missingFields($anime); + + if (!$force && empty($missing)) { + $this->line(" ATLA {$anime->title} — tüm alanlar dolu"); + $skipped++; + continue; + } + + $label = $force ? 'tüm alanlar' : implode(', ', $missing); + $this->line(" İŞLE [{$anime->id}] {$anime->title} — eksik: {$label}"); + + if ($dry) { + $done++; + continue; + } + + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + + if (!$meta) { + $this->warn(" HATA {$anime->title} — AI boş yanıt döndürdü"); + Log::channel('daily')->warning("[FillAnimeMeta] HATA [{$anime->id}] {$anime->title}: AI boş yanıt"); + $failed++; + sleep(3); + continue; + } + + // Sadece boş alanları doldur (force modunda hepsini güncelle) + $updates = []; + + $fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) { + if ($value === null || $value === '') return; + if ($force || empty($anime->$field)) { + $updates[$field] = $value; + } + }; + + $fillIfEmpty('description', $meta['description'] ?? null); + $fillIfEmpty('release_year', $meta['release_year'] ?? null); + $fillIfEmpty('studio', $meta['studio'] ?? null); + $fillIfEmpty('type', $meta['type'] ?? null); + $fillIfEmpty('status', $meta['status'] ?? null); + $fillIfEmpty('title_en', $meta['title_en'] ?? null); + $fillIfEmpty('title_jp', $meta['title_jp'] ?? null); + + // Rating: sadece boşsa veya 0 ise doldur + if (!empty($meta['rating']) && ($force || !$anime->rating)) { + $updates['rating'] = min(10, max(0, (float) $meta['rating'])); + } + + if (!empty($updates)) { + $anime->update($updates); + } + + // Genres: boşsa ekle + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $genreIds = []; + foreach ($meta['genres'] as $genreName) { + $genre = Genre::firstOrCreate( + ['name' => $genreName], + ['slug' => \Illuminate\Support\Str::slug($genreName)] + ); + $genreIds[] = $genre->id; + } + if ($genreIds) { + $force ? $anime->genres()->sync($genreIds) : $anime->genres()->syncWithoutDetaching($genreIds); + } + } + + $updatedFields = array_keys($updates); + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $updatedFields[] = 'genres(' . implode(',', $meta['genres'] ?? []) . ')'; + } + + $summary = empty($updatedFields) ? 'Yeni alan yok' : implode(', ', $updatedFields); + $this->info(" ✓ OK [{$anime->id}] {$anime->title} → {$summary}"); + Log::channel('daily')->info("[FillAnimeMeta] OK [{$anime->id}] {$anime->title} → {$summary}"); + + $done++; + + // API rate limit — DeepSeek'i boğma + sleep(2); + } + + $summary = "Tamamlandı: {$done} işlendi, {$skipped} atlandı, {$failed} hata."; + $this->info($summary); + Log::channel('daily')->info("[FillAnimeMeta] {$summary}"); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + private function missingFields(Anime $anime): array + { + $missing = []; + if (empty($anime->description)) $missing[] = 'description'; + if (empty($anime->release_year)) $missing[] = 'release_year'; + if (empty($anime->studio)) $missing[] = 'studio'; + if (empty($anime->type)) $missing[] = 'type'; + if (empty($anime->status)) $missing[] = 'status'; + if (!$anime->rating) $missing[] = 'rating'; + if (empty($anime->title_en)) $missing[] = 'title_en'; + if ($anime->genres->isEmpty()) $missing[] = 'genres'; + return $missing; + } +} diff --git a/app/Console/Commands/FixSubtitleMismatch.php b/app/Console/Commands/FixSubtitleMismatch.php new file mode 100644 index 0000000..5b048f2 --- /dev/null +++ b/app/Console/Commands/FixSubtitleMismatch.php @@ -0,0 +1,112 @@ +option('dry-run'); + $animeId = $this->option('anime-id'); + + $this->info("Anizium Altyazı Uyuşmazlık Düzeltici"); + $this->info("====================================="); + if ($dryRun) $this->warn("DRY-RUN modu — hiçbir şey silinmeyecek"); + + $query = Subtitle::query() + ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('subtitles.url') + ->where('subtitles.url', 'like', '%anizium%') + ->select( + 'subtitles.id as subtitle_id', + 'subtitles.episode_id', + 'subtitles.language', + 'subtitles.url', + 'seasons.season_number', + 'episodes.episode_number', + 'episodes.anime_id', + ); + + if ($animeId) { + $query->where('episodes.anime_id', (int) $animeId); + } + + $subtitles = $query->get(); + $this->info("Kontrol edilecek Anizium altyazısı: {$subtitles->count()}"); + + $mismatchIds = []; + + foreach ($subtitles as $sub) { + $url = $sub->url; + $season = (int) $sub->season_number; + $episode = (int) $sub->episode_number; + $lang = $sub->language; + + // URL'den name parametresini çıkar + $parsed = parse_url($url); + if (!isset($parsed['query'])) continue; + + parse_str($parsed['query'], $params); + $name = $params['name'] ?? ''; + + if (!$name) continue; + + // Beklenen: s{season}_b{episode}_{lang} + $expectedPrefix = "s{$season}_b{$episode}_"; + if (!str_starts_with($name, $expectedPrefix)) { + $mismatchIds[] = $sub->subtitle_id; + $this->line( + "[MISMATCH] sub_id={$sub->subtitle_id} " + . "anime_id={$sub->anime_id} " + . "S{$season}E{$episode} {$lang} " + . "| name={$name} " + . "(beklenen prefix: {$expectedPrefix})" + ); + } + } + + $this->newLine(); + $this->info("Uyuşmazlık bulunan: " . count($mismatchIds)); + + if (empty($mismatchIds)) { + $this->info("Düzeltilecek altyazı bulunamadı."); + return 0; + } + + if ($dryRun) { + $this->warn("--dry-run: {" . count($mismatchIds) . "} altyazı silinecekti."); + return 0; + } + + $deleted = Subtitle::whereIn('id', $mismatchIds)->delete(); + $this->info("Silindi: {$deleted} yanlış altyazı."); + $this->info("Botları yeniden çalıştırarak doğru altyazıları yeniden indirebilirsiniz."); + $this->info(" python anizium_scraper/bot2_upload.py --daemon"); + + return 0; + } +} diff --git a/app/Console/Commands/GenerateBlogPosts.php b/app/Console/Commands/GenerateBlogPosts.php new file mode 100644 index 0000000..ea32bc8 --- /dev/null +++ b/app/Console/Commands/GenerateBlogPosts.php @@ -0,0 +1,135 @@ +isConfigured()) { + $this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'); + return self::FAILURE; + } + + $count = (int) $this->option('count'); + $animeId = $this->option('anime'); + $force = $this->option('force'); + + if ($animeId) { + $animes = Anime::where('id', $animeId)->where('is_published', true)->with('genres')->get(); + } else { + $alreadyBlogged = $force ? [] : BlogPost::whereNotNull('anime_id')->pluck('anime_id')->toArray(); + $animesQuery = Anime::where('is_published', true) + ->whereNotIn('id', $alreadyBlogged) + ->with('genres') + ->orderByDesc('rating') + ->limit($count * 3) + ->get(); + + $randomResult = $animesQuery->random(min($count, $animesQuery->count())); + $animes = collect($randomResult); + } + + if ($animes->isEmpty()) { + $this->info('Blog yazısı üretilecek anime bulunamadı.'); + return self::SUCCESS; + } + + $generated = 0; + + foreach ($animes->take($count) as $anime) { + $this->info("Blog üretiliyor: {$anime->title}..."); + + // Aynı türden ilgili animeler bul + $genreIds = $anime->genres->pluck('id'); + $related = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds)) + ->orderByDesc('rating') + ->limit(5) + ->get(['id', 'title', 'slug']) + ->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title]) + ->toArray(); + + $data = $deepseek->generateBlogPost($anime, $related); + + if (!$data || empty($data['content'])) { + $this->warn(" [{$anime->title}] için içerik üretilemedi: " . $deepseek->lastError); + continue; + } + + // [LINK:slug]Title[/LINK] placeholder'larını gerçek URL'lerle değiştir + $content = preg_replace_callback( + '/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/', + function ($m) { + $slug = trim($m[1]); + $label = trim($m[2]); + try { + $url = route('anime.show', $slug); + return "{$label}"; + } catch (\Exception $e) { + return $label; + } + }, + $data['content'] ?? '' + ); + + $title = $data['title'] ?? ($anime->title . ' İzle — Animexe Rehberi'); + $slug = BlogPost::generateSlug($title); + + // Linked anime IDs + $linkedIds = []; + if (!empty($data['linked_slugs'])) { + $linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray(); + } + + $readingTime = max(3, (int) (str_word_count(strip_tags($content)) / 200)); + + BlogPost::create([ + 'title' => $title, + 'slug' => $slug, + 'excerpt' => $data['excerpt'] ?? '', + 'content' => $content, + 'cover_image' => $anime->cover_image, + 'focus_keyword' => $data['focus_keyword'] ?? $anime->title, + 'meta_title' => $data['title'] ?? null, + 'meta_description' => $data['meta_description'] ?? $data['excerpt'] ?? '', + 'meta_keywords' => implode(', ', array_filter([ + $anime->title, + $anime->title . ' izle', + 'türkçe anime', + $data['focus_keyword'] ?? '', + ])), + 'status' => 'published', + 'ai_generated' => true, + 'anime_id' => $anime->id, + 'linked_anime_ids' => $linkedIds, + 'faq' => $data['faq'] ?? [], + 'reading_time' => $readingTime, + 'published_at' => now(), + ]); + + $this->info(" ✓ Blog yazısı oluşturuldu: {$title}"); + $generated++; + + // API rate limit + sleep(2); + } + + $this->info("Tamamlandı. {$generated} blog yazısı üretildi."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GenerateDiscoveryHooks.php b/app/Console/Commands/GenerateDiscoveryHooks.php new file mode 100644 index 0000000..5c934ce --- /dev/null +++ b/app/Console/Commands/GenerateDiscoveryHooks.php @@ -0,0 +1,59 @@ +isConfigured()) { + $this->error('DeepSeek API anahtarı ayarlanmamış.'); + return 1; + } + + $limit = (int) $this->option('limit'); + $force = $this->option('force'); + + $query = Anime::where('is_published', true)->with('genres:id,name'); + if (!$force) { + $query->whereNull('discovery_hook'); + } + + $animes = $query->limit($limit)->get(); + + if ($animes->isEmpty()) { + $this->info('Hook üretilecek anime bulunamadı.'); + return 0; + } + + $this->info("Toplam {$animes->count()} anime için hook üretiliyor..."); + $bar = $this->output->createProgressBar($animes->count()); + $bar->start(); + + $done = 0; $failed = 0; + foreach ($animes as $anime) { + $hook = $ai->generateDiscoveryHook($anime); + if ($hook) { + $anime->updateQuietly(['discovery_hook' => $hook]); + $done++; + } else { + $failed++; + } + $bar->advance(); + usleep(300_000); // Rate limit — 0.3sn ara + } + + $bar->finish(); + $this->newLine(); + $this->info("Tamamlandı: {$done} başarılı, {$failed} başarısız."); + + return 0; + } +} diff --git a/app/Http/Controllers/Admin/ActivationCodeController.php b/app/Http/Controllers/Admin/ActivationCodeController.php new file mode 100644 index 0000000..b73b8e8 --- /dev/null +++ b/app/Http/Controllers/Admin/ActivationCodeController.php @@ -0,0 +1,142 @@ +latest(); + + if ($request->filled('plan_id')) { + $query->where('plan_id', $request->plan_id); + } + + if ($request->filled('batch')) { + $query->where('batch', $request->batch); + } + + match ($request->status) { + 'used' => $query->whereNotNull('used_at'), + 'unused' => $query->whereNull('used_at'), + default => null, + }; + + $codes = $query->paginate(50)->withQueryString(); + $plans = MembershipPlan::where('is_active', true)->orderBy('sort_order')->get(); + $batches = ActivationCode::select('batch')->whereNotNull('batch') + ->distinct()->orderBy('batch', 'desc')->pluck('batch'); + + $stats = [ + 'total' => ActivationCode::count(), + 'used' => ActivationCode::whereNotNull('used_at')->count(), + 'unused' => ActivationCode::whereNull('used_at')->count(), + ]; + + return view('admin.activation-codes.index', compact('codes', 'plans', 'stats', 'batches')); + } + + public function generate(Request $request) + { + $request->validate([ + 'plan_id' => 'required|exists:membership_plans,id', + 'quantity' => 'required|integer|min:1|max:500', + 'expires_at' => 'nullable|date|after:today', + 'notes' => 'nullable|string|max:500', + 'batch' => 'nullable|string|max:64', + ]); + + $batch = $request->batch ?: 'toplu-' . now()->format('Ymd-His'); + $generated = []; + + DB::transaction(function () use ($request, $batch, &$generated) { + for ($i = 0; $i < $request->quantity; $i++) { + $code = ActivationCode::create([ + 'code' => ActivationCode::generateCode(), + 'plan_id' => $request->plan_id, + 'expires_at' => $request->expires_at ?: null, + 'batch' => $batch, + 'notes' => $request->notes, + 'created_by' => auth()->id(), + ]); + $generated[] = $code->code; + } + }); + + return back() + ->with('generated_codes', $generated) + ->with('success', count($generated) . ' adet aktivasyon kodu oluşturuldu. (Batch: ' . $batch . ')'); + } + + public function destroy(ActivationCode $activationCode) + { + if ($activationCode->isUsed()) { + return back()->withErrors(['error' => 'Kullanılmış kodlar silinemez.']); + } + + $activationCode->delete(); + + return back()->with('success', 'Aktivasyon kodu silindi.'); + } + + public function destroyBatch(Request $request) + { + $request->validate(['batch' => 'required|string|max:64']); + + $count = ActivationCode::where('batch', $request->batch) + ->whereNull('used_at') + ->delete(); + + return back()->with('success', $count . ' adet kullanılmamış kod silindi.'); + } + + public function destroySelected(Request $request) + { + $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer|exists:activation_codes,id']); + + $count = ActivationCode::whereIn('id', $request->ids) + ->whereNull('used_at') + ->delete(); + + return back()->with('success', $count . ' adet aktivasyon kodu silindi.'); + } + + public function export(Request $request) + { + $query = ActivationCode::with('plan')->whereNull('used_at'); + + if ($request->filled('plan_id')) { + $query->where('plan_id', $request->plan_id); + } + + if ($request->filled('batch')) { + $query->where('batch', $request->batch); + } + + $codes = $query->orderBy('batch')->orderBy('created_at')->get(); + + $csv = "\xEF\xBB\xBF"; // UTF-8 BOM (Excel için) + $csv .= "Kod,Plan,Batch,Son Kullanma,Oluşturulma\n"; + + foreach ($codes as $code) { + $csv .= implode(',', [ + $code->code, + '"' . str_replace('"', '""', $code->plan->name) . '"', + $code->batch ?? '-', + $code->expires_at?->format('d.m.Y') ?? '-', + $code->created_at->format('d.m.Y H:i'), + ]) . "\n"; + } + + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="aktivasyon-kodlari-' . now()->format('Ymd') . '.csv"', + ]); + } +} diff --git a/app/Http/Controllers/Admin/AdController.php b/app/Http/Controllers/Admin/AdController.php new file mode 100644 index 0000000..766890f --- /dev/null +++ b/app/Http/Controllers/Admin/AdController.php @@ -0,0 +1,187 @@ +get(); + + $settings = [ + 'vad_enabled' => Setting::get('vad_enabled', '0'), + 'vad_freq_episodes' => Setting::get('vad_freq_episodes', 2), + 'vad_freq_minutes' => Setting::get('vad_freq_minutes', 5), + 'vad_upsell_percent' => Setting::get('vad_upsell_percent', 20), + 'banner_ads_enabled' => Setting::get('banner_ads_enabled', '0'), + ]; + + $stats = [ + 'total_impressions' => $ads->sum('impressions'), + 'total_clicks' => $ads->sum('clicks'), + 'avg_ctr' => $ads->sum('impressions') > 0 + ? round($ads->sum('clicks') / $ads->sum('impressions') * 100, 2) : 0, + 'active_count' => $ads->where('is_active', true)->count(), + ]; + + return view('admin.ads.index', compact('ads', 'settings', 'stats')); + } + + public function store(Request $request) + { + $data = $this->validateAd($request); + + if ($request->hasFile('media_file')) { + $data['file_path'] = $this->storeMedia($request->file('media_file')); + } + + unset($data['media_file']); + Ad::create($data); + + return back()->with('success', 'Reklam eklendi.'); + } + + public function edit(Ad $ad) + { + return view('admin.ads.edit', compact('ad')); + } + + public function update(Request $request, Ad $ad) + { + $data = $this->validateAd($request, $ad); + + if ($request->hasFile('media_file')) { + $newPath = $this->storeMedia($request->file('media_file')); + if ($ad->file_path) Storage::disk('public')->delete($ad->file_path); + $data['file_path'] = $newPath; + } + + unset($data['media_file']); + $ad->update($data); + + return redirect()->route('admin.ads.index')->with('success', 'Reklam güncellendi.'); + } + + public function destroy(Ad $ad) + { + if ($ad->file_path) Storage::disk('public')->delete($ad->file_path); + $ad->delete(); + + return back()->with('success', 'Reklam silindi.'); + } + + public function toggle(Ad $ad) + { + $ad->update(['is_active' => !$ad->is_active]); + return back()->with('success', $ad->is_active ? 'Reklam aktifleştirildi.' : 'Reklam durduruldu.'); + } + + public function saveSettings(Request $request) + { + Setting::set('vad_enabled', $request->boolean('vad_enabled') ? '1' : '0', 'ads'); + Setting::set('vad_freq_episodes', max(1, (int) $request->input('vad_freq_episodes', 2)), 'ads'); + Setting::set('vad_freq_minutes', max(1, (int) $request->input('vad_freq_minutes', 5)), 'ads'); + Setting::set('vad_upsell_percent', min(100, max(0, (int) $request->input('vad_upsell_percent', 20))), 'ads'); + Setting::set('banner_ads_enabled', $request->boolean('banner_ads_enabled') ? '1' : '0', 'ads'); + + return back()->with('success', 'Reklam ayarları kaydedildi.'); + } + + private function validateAd(Request $request, ?Ad $existing = null): array + { + $type = $request->input('type', 'video'); + + // Sunucu upload limitini aşan dosya: PHP boş/bozuk upload gönderir. + // Sessizce medyasız reklam kaydetmek yerine net hata ver. + $this->guardUploadError($request); + + // Yüklenmiş dosya da dış URL de yoksa reklam gösterilemez (media_url null olur). + // Düzenlemede mevcut dosya varsa yeniden yükleme zorunlu değil. + $hasExisting = $existing?->file_path || $existing?->external_url; + $needsMedia = !$request->hasFile('media_file') && !$hasExisting; + + $data = $request->validate([ + 'name' => 'required|string|max:120', + 'type' => 'required|in:video,banner', + 'placement' => 'required|in:preroll,home_mid,home_bottom', + 'media_file' => [ + 'nullable', 'file', + $type === 'video' ? 'mimes:mp4,m4v' : 'mimes:jpg,jpeg,png,webp,gif', + $type === 'video' ? 'max:102400' : 'max:20480', // video 100MB, görsel/gif 20MB + ], + 'external_url' => [$needsMedia ? 'required' : 'nullable', 'nullable', 'url', 'max:2000'], + 'click_url' => 'nullable|url|max:2000', + 'skip_after' => 'required|integer|min:0|max:60', + 'weight' => 'required|integer|min:1|max:100', + 'is_active' => 'boolean', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after:starts_at', + ], [ + 'external_url.required' => 'Bir medya dosyası yükleyin veya dış URL girin. ' + . 'Dosya seçtiyseniz sunucu yükleme limitini aşmış olabilir (maks. ' + . ini_get('upload_max_filesize') . ').', + 'media_file.mimes' => $type === 'video' + ? 'Video dosyası MP4 formatında olmalı.' + : 'Görsel JPG, PNG, WebP veya GIF formatında olmalı.', + 'media_file.max' => 'Dosya çok büyük.', + ]); + + // Checkbox işaretli değilse request'te hiç gelmez — açıkça boolean'a çevir + $data['is_active'] = $request->boolean('is_active'); + + return $data; + } + + /** PHP upload hatalarını (limit aşımı, kısmi yükleme) net mesajla yüzeye çıkar. */ + private function guardUploadError(Request $request): void + { + $file = $request->file('media_file'); + if (!$file || $file->isValid()) { + return; + } + + $msg = match ($file->getError()) { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => + 'Dosya sunucunun yükleme limitini aşıyor (maks. ' . ini_get('upload_max_filesize') + . '). Daha küçük bir dosya seçin veya hosting limitini yükseltin.', + UPLOAD_ERR_PARTIAL => 'Dosya yalnızca kısmen yüklendi, lütfen tekrar deneyin.', + UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => + 'Sunucu dosyayı geçici klasöre yazamadı. Hosting sağlayıcınıza bildirin.', + default => 'Dosya yüklenemedi (hata kodu: ' . $file->getError() . ').', + }; + + throw \Illuminate\Validation\ValidationException::withMessages(['media_file' => $msg]); + } + + /** Dosyayı public diske yaz ve tam yazıldığını doğrula. */ + private function storeMedia(\Illuminate\Http\UploadedFile $file): string + { + // NOT: klasör adı bilerek nötr ('ads' değil) — adblocker /media/ads/ yolunu + // ERR_BLOCKED_BY_CLIENT ile engelliyor. 'content' engellenmez. + $expected = $file->getSize(); + $path = $file->store('content', 'public'); + + if (!$path || !Storage::disk('public')->exists($path)) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'media_file' => 'Dosya sunucuya kaydedilemedi. storage/app/public klasörünün yazma izni olduğundan emin olun.', + ]); + } + + // Kısmi yazma (disk dolu / kesilen upload) sessizce bozuk reklam bırakmasın + $written = Storage::disk('public')->size($path); + if ($expected > 0 && $written !== $expected) { + Storage::disk('public')->delete($path); + throw \Illuminate\Validation\ValidationException::withMessages([ + 'media_file' => "Dosya eksik yüklendi ({$written}/{$expected} byte). Tekrar deneyin.", + ]); + } + + return $path; + } +} diff --git a/app/Http/Controllers/Admin/AiController.php b/app/Http/Controllers/Admin/AiController.php new file mode 100644 index 0000000..8548904 --- /dev/null +++ b/app/Http/Controllers/Admin/AiController.php @@ -0,0 +1,267 @@ +whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ->orWhereNull('studio')->orWhere('studio', '') + ->orWhereNull('type')->orWhere('type', '') + ->orWhereNull('status')->orWhere('status', ''); + })->count(); + + $noGenres = Anime::doesntHave('genres')->count(); + + return view('admin.ai.anime-meta', compact('total', 'missing', 'noGenres')); + } + + /** + * POST /admin/ai/anime-meta-ids — eksik animelerin ID listesini döndür. + */ + public function animeMetaIds(Request $request) + { + $force = $request->boolean('force'); + + $query = Anime::with('genres:id')->select('id', 'title', 'description', 'release_year', 'studio', 'type', 'status', 'rating', 'title_en', 'title_jp'); + + if (!$force) { + $query->where(function ($q) { + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ->orWhereNull('studio')->orWhere('studio', '') + ->orWhereNull('type')->orWhere('type', '') + ->orWhereNull('status')->orWhere('status', ''); + })->orDoesntHave('genres'); + } + + $animes = $query->orderBy('id')->get()->map(function ($a) { + $missing = []; + if (empty($a->description)) $missing[] = 'açıklama'; + if (empty($a->release_year)) $missing[] = 'yıl'; + if (empty($a->studio)) $missing[] = 'stüdyo'; + if (empty($a->type)) $missing[] = 'tür'; + if (empty($a->status)) $missing[] = 'durum'; + if (!$a->rating) $missing[] = 'puan'; + if ($a->genres->isEmpty()) $missing[] = 'kategoriler'; + return ['id' => $a->id, 'title' => $a->title, 'missing' => $missing]; + }); + + return response()->json(['animes' => $animes]); + } + + /** + * POST /admin/ai/fill-anime-meta — tek anime için meta doldur ve kaydet. + */ + public function fillAnimeMeta(Request $request) + { + $anime = Anime::with('genres:id,name')->findOrFail($request->anime_id); + $force = $request->boolean('force'); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + + if (!$meta) { + Log::channel('daily')->warning("[FillAnimeMeta-UI] HATA [{$anime->id}] {$anime->title}"); + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü.'], 500); + } + + $updates = []; + $fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) { + if ($value === null || $value === '') return; + if ($force || empty($anime->$field)) $updates[$field] = $value; + }; + + $fillIfEmpty('description', $meta['description'] ?? null); + $fillIfEmpty('release_year', $meta['release_year'] ?? null); + $fillIfEmpty('studio', $meta['studio'] ?? null); + $fillIfEmpty('type', $meta['type'] ?? null); + $fillIfEmpty('status', $meta['status'] ?? null); + $fillIfEmpty('title_en', $meta['title_en'] ?? null); + $fillIfEmpty('title_jp', $meta['title_jp'] ?? null); + if (!empty($meta['rating']) && ($force || !$anime->rating)) { + $updates['rating'] = min(10, max(0, (float) $meta['rating'])); + } + + if (!empty($updates)) $anime->update($updates); + + $syncedGenres = []; + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $ids = []; + foreach ($meta['genres'] as $name) { + $g = Genre::firstOrCreate(['name' => $name], ['slug' => Str::slug($name)]); + $ids[] = $g->id; + } + if ($ids) { + $force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids); + $syncedGenres = $meta['genres']; + } + } + + $filled = array_keys($updates); + if ($syncedGenres) $filled[] = 'kategoriler'; + + Log::channel('daily')->info("[FillAnimeMeta-UI] OK [{$anime->id}] {$anime->title} → " . implode(', ', $filled)); + + return response()->json([ + 'ok' => true, + 'filled' => $filled, + 'meta' => array_merge($updates, ['genres' => $syncedGenres]), + ]); + } + + /** + * Toplu açıklama yazma sayfası. + */ + public function descriptionsPage() + { + $animes = Anime::orderBy('title') + ->withCount(['episodes as total_eps' => fn($q) => $q->whereNull('description')->orWhere('description', '')]) + ->get() + ->filter(fn($a) => $a->total_eps > 0); + + $totalMissing = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count(); + + return view('admin.ai.descriptions', compact('animes', 'totalMissing')); + } + + /** + * Açıklaması olmayan bölüm ID'lerini döndür (JS için). + * POST { anime_id: 0=tümü } + */ + public function episodeIds(Request $request) + { + $query = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', '')); + + if ($request->anime_id && $request->anime_id != '0') { + $query->where('anime_id', $request->anime_id); + } + + $ids = $query->with('anime:id,title')->get()->map(fn($ep) => [ + 'id' => $ep->id, + 'label' => ($ep->anime->title ?? '?') . ' — ' . $ep->episode_number . '. Bölüm' . ($ep->title ? ' — '.$ep->title : ''), + ]); + + return response()->json(['episodes' => $ids]); + } + + /** + * Tek bir bölüme açıklama yaz ve kaydet. + * POST { episode_id } + */ + public function fillOne(Request $request) + { + $episode = Episode::with('anime:id,title')->findOrFail($request->episode_id); + + if (!empty($episode->description)) { + return response()->json(['ok' => true, 'skipped' => true, 'description' => $episode->description]); + } + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $desc = $ai->generateEpisodeDescription( + $episode->anime->title ?? 'Bilinmeyen', + $episode->episode_number, + $episode->title ?? '' + ); + + if (!$desc) { + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500); + } + + $episode->update(['description' => $desc]); + return response()->json(['ok' => true, 'description' => $desc]); + } + + /** + * Anime için tüm meta verileri AI ile doldur. + * POST { anime_id, title?, title_jp? } + * Döner: { description, release_year, studio, type, status, rating, title_en, title_jp, genres[] } + */ + public function animeMeta(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $title = trim($request->input('title', '')); + $titleJp = trim($request->input('title_jp', '')); + + if (!$title) { + return response()->json(['error' => 'Başlık boş olamaz.'], 422); + } + + $meta = $ai->generateAnimeMeta($title, $titleJp); + + if (!$meta) { + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500); + } + + return response()->json(['ok' => true, 'meta' => $meta]); + } + + /** + * DeepSeek ile Türkçe açıklama üret. + * POST body: { type: 'anime'|'episode', title, title_jp?, anime_title?, episode_number?, genres? } + */ + public function generate(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek bölümüne ekleyin.'], 422); + } + + $type = $request->input('type', 'anime'); + $title = trim($request->input('title', '')); + if (!$title) { + return response()->json(['error' => 'Başlık boş olamaz.'], 422); + } + + if ($type === 'episode') { + $desc = $ai->generateEpisodeDescription( + trim($request->input('anime_title', $title)), + (int) $request->input('episode_number', 1), + trim($request->input('episode_title', '')) + ); + } else { + $desc = $ai->generateAnimeDescription( + $title, + trim($request->input('title_jp', '')), + trim($request->input('genres', '')) + ); + } + + if (!$desc) { + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya bağlantı hatası.'], 500); + } + + return response()->json(['description' => $desc]); + } +} diff --git a/app/Http/Controllers/Admin/AnalyticsController.php b/app/Http/Controllers/Admin/AnalyticsController.php new file mode 100644 index 0000000..d6614c1 --- /dev/null +++ b/app/Http/Controllers/Admin/AnalyticsController.php @@ -0,0 +1,358 @@ +input('period', '7d'); + $from = match ($period) { + 'today' => now()->startOfDay(), + '30d' => now()->subDays(30), + '90d' => now()->subDays(90), + default => now()->subDays(7), + }; + + $cacheKey = 'admin_analytics_' . $period; + $cached = Cache::remember($cacheKey, 300, function () use ($from, $period) { + return $this->buildAnalytics($from, $period); + }); + extract($cached); + + // Gerçek zamanlı veriler (cache'lenmiyor) + $recentViews = PageView::with('user:id,name') + ->where('created_at', '>=', $from) + ->orderByDesc('id') + ->limit(20) + ->get(); + + $blockedIps = collect(); + $recentBots = collect(); + try { + $blockedIps = DB::table('blocked_ips')->orderByDesc('blocked_at')->limit(20)->get(); + $recentBots = DB::table('analytics_bot_logs')->orderByDesc('id')->limit(30)->get(); + } catch (\Exception) {} + + return view('admin.analytics.index', compact( + 'period', 'from', + 'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers', + 'viewsDelta', 'todayViews', 'yesterdayViews', + 'trendLabels', 'trendData', 'watchTrendData', + 'hourlyData', + 'topAnimes', + 'topEpisodes', + 'deviceStats', 'browserStats', 'pageTypeStats', + 'geoStats', + 'activeUsers', + 'aiByType', 'aiTopQuestions', 'aiTopUsers', + 'recentViews', + 'referrerStats', 'directTraffic', + 'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName', + 'blockedIps', 'recentBots', + 'sessions', 'avgSessionTime', 'avgPages', + )); + } + + private function buildAnalytics($from, string $period): array + { + // ── Özet kartlar ────────────────────────────────────────────────────── + $totalViews = PageView::where('created_at', '>=', $from)->count(); + $uniqueVisitors = PageView::where('created_at', '>=', $from)->distinct('session_id')->count('session_id'); + $watchSeconds = WatchEvent::where('created_at', '>=', $from)->sum('seconds_watched'); + $aiTotal = AiQuery::where('created_at', '>=', $from)->count(); + $newUsers = User::where('created_at', '>=', $from)->count(); + + $yesterday = now()->subDay(); + $todayViews = PageView::where('created_at', '>=', now()->startOfDay())->count(); + $yesterdayViews = PageView::whereBetween('created_at', [$yesterday->startOfDay(), $yesterday->endOfDay()])->count(); + $viewsDelta = $yesterdayViews > 0 ? round(($todayViews - $yesterdayViews) / $yesterdayViews * 100) : 0; + + // ── Görüntüleme trendi (gün bazlı) ──────────────────────────────────── + $viewsByDay = PageView::where('created_at', '>=', $from) + ->selectRaw('DATE(created_at) as date, COUNT(*) as cnt') + ->groupBy('date') + ->orderBy('date') + ->pluck('cnt', 'date'); + + $trendLabels = []; + $trendData = []; + $cur = clone $from; + while ($cur->lte(now())) { + $key = $cur->format('Y-m-d'); + $trendLabels[] = $cur->format($period === 'today' ? 'H:i' : 'd M'); + $trendData[] = $viewsByDay[$key] ?? 0; + $cur->addDay(); + } + + // ── Saatlik dağılım (bugün) ─────────────────────────────────────────── + $hourlyRaw = PageView::where('created_at', '>=', now()->startOfDay()) + ->selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt') + ->groupBy('hour') + ->pluck('cnt', 'hour'); + $hourlyData = array_map(fn($h) => $hourlyRaw[$h] ?? 0, range(0, 23)); + + // ── İzleme süresi trendi ───────────────────────────────────────────── + $watchByDay = WatchEvent::where('created_at', '>=', $from) + ->selectRaw('DATE(created_at) as date, ROUND(SUM(seconds_watched)/3600, 1) as hours') + ->groupBy('date') + ->orderBy('date') + ->pluck('hours', 'date'); + $watchTrendData = array_map(fn($k) => (float)($watchByDay[$k] ?? 0), array_keys(array_flip($trendLabels))); + + // ── Top 10 anime ───────────────────────────────────────────────────── + $topAnimeIds = PageView::where('created_at', '>=', $from) + ->whereNotNull('anime_id') + ->selectRaw('anime_id, COUNT(*) as cnt') + ->groupBy('anime_id') + ->orderByDesc('cnt') + ->limit(10) + ->pluck('cnt', 'anime_id'); + + $topAnimes = Anime::whereIn('id', $topAnimeIds->keys()) + ->get(['id', 'title', 'cover_image']) + ->map(fn($a) => [ + 'title' => $a->title, + 'views' => $topAnimeIds[$a->id] ?? 0, + 'cover' => $a->cover_url, + 'slug' => $a->slug, + ]) + ->sortByDesc('views') + ->values(); + + // ── Top bölümler ───────────────────────────────────────────────────── + $topEpisodes = WatchEvent::where('analytics_watch_events.created_at', '>=', $from) + ->selectRaw('anime_id, season_number, episode_number, episode_id, + SUM(seconds_watched) as total_sec, + COUNT(*) as plays, + ROUND(AVG(percent_complete), 0) as avg_pct') + ->groupBy('anime_id', 'season_number', 'episode_number', 'episode_id') + ->orderByDesc('total_sec') + ->limit(10) + ->get(); + + $epAnimes = Anime::whereIn('id', $topEpisodes->pluck('anime_id')->unique())->pluck('title', 'id'); + $topEpisodes = $topEpisodes->map(fn($e) => [ + 'anime' => $epAnimes[$e->anime_id] ?? 'Bilinmiyor', + 'label' => "S{$e->season_number}E{$e->episode_number}", + 'plays' => $e->plays, + 'hours' => round($e->total_sec / 3600, 1), + 'avg_pct' => $e->avg_pct, + ]); + + // ── Cihaz / tarayıcı / sayfa türü ──────────────────────────────────── + $deviceStats = PageView::where('created_at', '>=', $from) + ->selectRaw('device, COUNT(*) as cnt') + ->groupBy('device') + ->pluck('cnt', 'device'); + + $browserStats = PageView::where('created_at', '>=', $from) + ->selectRaw('browser, COUNT(*) as cnt') + ->groupBy('browser') + ->orderByDesc('cnt') + ->pluck('cnt', 'browser'); + + $pageTypeStats = PageView::where('created_at', '>=', $from) + ->selectRaw('page_type, COUNT(*) as cnt') + ->groupBy('page_type') + ->orderByDesc('cnt') + ->pluck('cnt', 'page_type'); + + // ── Coğrafi dağılım ─────────────────────────────────────────────────── + $geoStats = PageView::where('created_at', '>=', $from) + ->whereNotNull('city') + ->selectRaw('city, country, COUNT(*) as cnt') + ->groupBy('city', 'country') + ->orderByDesc('cnt') + ->limit(15) + ->get(['city', 'country', DB::raw('COUNT(*) as cnt')]); + + // ── En aktif kullanıcılar ───────────────────────────────────────────── + $activeUserIds = PageView::where('created_at', '>=', $from) + ->whereNotNull('user_id') + ->selectRaw('user_id, COUNT(*) as views, COUNT(DISTINCT DATE(created_at)) as days') + ->groupBy('user_id') + ->orderByDesc('views') + ->limit(10) + ->get(); + + $activeUserList = User::whereIn('id', $activeUserIds->pluck('user_id')) + ->get(['id', 'name', 'email', 'created_at']) + ->keyBy('id'); + + $activeUsers = $activeUserIds->map(fn($r) => [ + 'user' => $activeUserList[$r->user_id] ?? null, + 'views' => $r->views, + 'days' => $r->days, + ])->filter(fn($r) => $r['user']); + + // ── AI istatistikleri ───────────────────────────────────────────────── + $aiByType = AiQuery::where('created_at', '>=', $from) + ->selectRaw('query_type, COUNT(*) as cnt') + ->groupBy('query_type') + ->orderByDesc('cnt') + ->pluck('cnt', 'query_type'); + + $aiTopQuestions = AiQuery::where('created_at', '>=', $from) + ->where('query_type', 'chat') + ->whereNotNull('query_text') + ->selectRaw('query_text, COUNT(*) as cnt') + ->groupBy('query_text') + ->orderByDesc('cnt') + ->limit(10) + ->get(); + + $aiByUser = AiQuery::where('created_at', '>=', $from) + ->whereNotNull('user_id') + ->selectRaw('user_id, COUNT(*) as cnt') + ->groupBy('user_id') + ->orderByDesc('cnt') + ->limit(5) + ->get(); + + $aiUserList = User::whereIn('id', $aiByUser->pluck('user_id'))->pluck('name', 'id'); + $aiTopUsers = $aiByUser->map(fn($r) => [ + 'name' => $aiUserList[$r->user_id] ?? 'Bilinmiyor', + 'cnt' => $r->cnt, + ]); + + // ── Referrer ───────────────────────────────────────────────────────── + $referrerRaw = PageView::where('created_at', '>=', $from) + ->whereNotNull('referrer') + ->where('referrer', '!=', '') + ->selectRaw('referrer, COUNT(*) as cnt') + ->groupBy('referrer') + ->orderByDesc('cnt') + ->limit(30) + ->pluck('cnt', 'referrer'); + + $referrerStats = collect(); + foreach ($referrerRaw as $url => $cnt) { + try { + $parsed = parse_url($url); + $domain = $parsed['host'] ?? $url; + $domain = preg_replace('/^www\./', '', $domain); + } catch (\Throwable) { + $domain = $url; + } + if ($referrerStats->has($domain)) { + $referrerStats[$domain] += $cnt; + } else { + $referrerStats[$domain] = $cnt; + } + } + $referrerStats = $referrerStats->sortDesc()->take(15); + + $directTraffic = PageView::where('created_at', '>=', $from) + ->where(fn($q) => $q->whereNull('referrer')->orWhere('referrer', '')) + ->count(); + + // ── Bot istatistikleri ──────────────────────────────────────────────── + $botViews = 0; + $humanViews = 0; + $botRatio = 0; + $botTopIps = collect(); + $botByName = collect(); + + try { + $botViews = PageView::where('created_at', '>=', $from)->where('is_bot', 1)->count(); + $humanViews = PageView::where('created_at', '>=', $from)->where('is_bot', 0)->count(); + $botRatio = ($botViews + $humanViews) > 0 ? round($botViews / ($botViews + $humanViews) * 100) : 0; + + $botTopIps = DB::table('analytics_bot_logs') + ->where('created_at', '>=', $from) + ->selectRaw('ip, COUNT(*) as cnt, MAX(user_agent) as ua, MAX(action) as action') + ->groupBy('ip') + ->orderByDesc('cnt') + ->limit(15) + ->get(); + + $botByName = DB::table('analytics_bot_logs') + ->where('created_at', '>=', $from) + ->selectRaw('bot_name, COUNT(*) as cnt, action') + ->groupBy('bot_name', 'action') + ->orderByDesc('cnt') + ->limit(20) + ->get(); + } catch (\Exception) {} + + // ── Oturum istatistikleri ───────────────────────────────────────────── + $sessions = collect(); + $avgSessionTime = 0; + $avgPages = 0; + + try { + $avgSessionTime = (int) DB::table('analytics_sessions') + ->where('started_at', '>=', $from) + ->where('is_bot', 0) + ->avg('total_seconds'); + + $avgPages = round((float) DB::table('analytics_sessions') + ->where('started_at', '>=', $from) + ->where('is_bot', 0) + ->avg('pages_visited'), 1); + + $sessions = DB::table('analytics_sessions') + ->where('started_at', '>=', $from) + ->orderByDesc('started_at') + ->limit(30) + ->get(); + } catch (\Exception) {} + + return compact( + 'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers', + 'viewsDelta', 'todayViews', 'yesterdayViews', + 'trendLabels', 'trendData', 'watchTrendData', + 'hourlyData', + 'topAnimes', 'topEpisodes', + 'deviceStats', 'browserStats', 'pageTypeStats', + 'geoStats', + 'activeUsers', + 'aiByType', 'aiTopQuestions', 'aiTopUsers', + 'referrerStats', 'directTraffic', + 'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName', + 'sessions', 'avgSessionTime', 'avgPages' + ); + } + + public function blockIp(Request $request) + { + $data = $request->validate([ + 'ip' => 'required|ip', + 'reason' => 'nullable|string|max:255', + 'expires_at' => 'nullable|date|after:now', + ]); + + DB::table('blocked_ips')->updateOrInsert( + ['ip' => $data['ip']], + [ + 'reason' => $data['reason'] ?? 'Manuel engel', + 'auto_blocked' => 0, + 'blocked_at' => now(), + 'expires_at' => $data['expires_at'] ?? null, + ] + ); + + \Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $data['ip']); + return back()->with('success', $data['ip'] . ' engellendi.'); + } + + public function unblockIp(Request $request) + { + $ip = $request->input('ip'); + DB::table('blocked_ips')->where('ip', $ip)->delete(); + \Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $ip); + return back()->with('success', $ip . ' engeli kaldırıldı.'); + } +} diff --git a/app/Http/Controllers/Admin/AnimeController.php b/app/Http/Controllers/Admin/AnimeController.php new file mode 100644 index 0000000..7439a38 --- /dev/null +++ b/app/Http/Controllers/Admin/AnimeController.php @@ -0,0 +1,412 @@ +latest(); + + if ($request->search) { + $query->where('title', 'like', '%' . $request->search . '%'); + } + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->type) { + $query->where('type', $request->type); + } + if ($request->no_episodes) { + $query->whereDoesntHave('episodes'); + } + + $animes = $query->paginate(20)->withQueryString(); + $zeroEpisodeCount = Anime::whereDoesntHave('episodes')->count(); + return view('admin.animes.index', compact('animes', 'zeroEpisodeCount')); + } + + public function destroyZeroEpisodes() + { + $animes = Anime::whereDoesntHave('episodes')->get(); + $count = $animes->count(); + foreach ($animes as $anime) { + $anime->delete(); + } + return response()->json(['success' => true, 'count' => $count]); + } + + public function create() + { + $genres = Genre::where('is_active', true)->get(); + $permissions = PermissionSetting::all(); + return view('admin.animes.create', compact('genres', 'permissions')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'title_en' => 'nullable|string|max:255', + 'title_jp' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:series,movie,ova,ona,special', + 'status' => 'required|in:ongoing,completed,upcoming', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'studio' => 'nullable|string|max:255', + 'rating' => 'nullable|numeric|min:0|max:10', + 'mal_id' => 'nullable|string|max:50', + 'trailer_url' => 'nullable|url', + 'is_featured' => 'boolean', + 'is_published' => 'boolean', + 'is_dubbed' => 'boolean', + ]); + + $data['slug'] = Str::slug($data['title']); + $data['is_featured'] = $request->boolean('is_featured'); + $data['is_published'] = $request->boolean('is_published'); + $data['is_dubbed'] = $request->boolean('is_dubbed'); + + // Auto-fetch MAL ID if not provided + if (empty($data['mal_id'])) { + try { + $data['mal_id'] = (new JikanService())->searchMalId( + $data['title'], + $data['title_en'] ?? null, + $data['title_jp'] ?? null, + $data['type'] ?? null, + ); + } catch (\Throwable) {} + } + + if ($request->hasFile('cover_image')) { + $data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover'); + } + if ($request->hasFile('banner_image')) { + $data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner'); + } + + $anime = Anime::create($data); + + if ($request->genres) { + $anime->genres()->sync($request->genres); + } + + // Auto-fill season MAL IDs if mal_id was found + if ($anime->mal_id) { + dispatch(function () use ($anime) { + try { + $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (isset($chain[$i])) $season->update(['mal_id' => $chain[$i]]); + } + } catch (\Throwable) {} + })->afterResponse(); + } + + return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime eklendi.'); + } + + public function show(Anime $anime) + { + $anime->load(['genres', 'seasons.episodes']); + $permissions = PermissionSetting::all(); + $contentPerms = ContentPermission::where('content_type', 'anime') + ->where('content_id', $anime->id) + ->pluck('required_membership', 'permission_key'); + + return view('admin.animes.show', compact('anime', 'permissions', 'contentPerms')); + } + + public function edit(Anime $anime) + { + $genres = Genre::where('is_active', true)->get(); + $permissions = PermissionSetting::all(); + $contentPerms = ContentPermission::where('content_type', 'anime') + ->where('content_id', $anime->id) + ->pluck('required_membership', 'permission_key'); + + return view('admin.animes.edit', compact('anime', 'genres', 'permissions', 'contentPerms')); + } + + public function update(Request $request, Anime $anime) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'title_en' => 'nullable|string|max:255', + 'title_jp' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:series,movie,ova,ona,special', + 'status' => 'required|in:ongoing,completed,upcoming', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'studio' => 'nullable|string|max:255', + 'rating' => 'nullable|numeric|min:0|max:10', + 'mal_id' => 'nullable|string|max:50', + 'trailer_url' => 'nullable|url', + 'is_featured' => 'boolean', + 'is_published' => 'boolean', + 'is_dubbed' => 'boolean', + ]); + + $data['is_featured'] = $request->boolean('is_featured'); + $data['is_published'] = $request->boolean('is_published'); + $data['is_dubbed'] = $request->boolean('is_dubbed'); + + // Auto-fetch MAL ID if not provided and anime doesn't already have one + if (empty($data['mal_id']) && empty($anime->mal_id)) { + try { + $data['mal_id'] = (new JikanService())->searchMalId( + $data['title'], + $data['title_en'] ?? null, + $data['title_jp'] ?? null, + ); + } catch (\Throwable) {} + } + + if ($request->hasFile('cover_image')) { + ImageOptimizer::delete($anime->cover_image); + $data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover'); + } + if ($request->hasFile('banner_image')) { + ImageOptimizer::delete($anime->banner_image); + $data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner'); + } + + $anime->update($data); + + if ($request->has('genres')) { + $anime->genres()->sync($request->genres ?? []); + } + + // MAL ID değiştiyse: AniSkip cache'lerini temizle + sezon MAL ID'lerini doldur + if ($anime->mal_id) { + dispatch(function () use ($anime) { + try { + // AniSkip null cache'lerini temizle (tüm bölümler için) + foreach ($anime->seasons as $s) { + if ($s->mal_id) { + foreach ($anime->episodes()->where('season_id', $s->id)->pluck('episode_number') as $epNum) { + \Illuminate\Support\Facades\Cache::forget("aniskip_{$s->mal_id}_{$epNum}"); + } + } + } + // S1 için doğrudan anime.mal_id kullan + $s1 = $anime->seasons()->where('season_number', 1)->first(); + if ($s1 && !$s1->mal_id) { + $s1->update(['mal_id' => $anime->mal_id]); + foreach ($anime->episodes()->where('season_id', $s1->id)->pluck('episode_number') as $epNum) { + \Illuminate\Support\Facades\Cache::forget("aniskip_{$anime->mal_id}_{$epNum}"); + } + } + // S2+ için Jikan chain + $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); + \Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (!$season->mal_id && isset($chain[$i])) { + $season->update(['mal_id' => $chain[$i]]); + } + } + } catch (\Throwable) {} + })->afterResponse(); + } + + return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime güncellendi.'); + } + + public function destroy(Anime $anime) + { + // CDN klasörü için örnek bir video_url al (anime_XXXXX/ path'ini çıkarmak için) + $sampleVideoUrl = $anime->episodes()->whereNotNull('video_url')->value('video_url'); + + $anime->delete(); + + // CDN'den tüm anime klasörünü arka planda sil (anime_XXXXX/season_X/...) + dispatch(function () use ($sampleVideoUrl) { + \App\Services\BunnyCdnStorage::deleteAnimeFolder($sampleVideoUrl); + })->afterResponse(); + + return redirect()->route('admin.animes.index')->with('success', 'Anime silindi.'); + } + + public function updatePermissions(Request $request, Anime $anime) + { + $permissions = $request->permissions ?? []; + + // Mevcut override'ları sil + ContentPermission::where('content_type', 'anime') + ->where('content_id', $anime->id) + ->delete(); + + // Yeni override'ları kaydet + foreach ($permissions as $key => $value) { + if (in_array($value, ['free', 'premium'])) { + ContentPermission::create([ + 'content_type' => 'anime', + 'content_id' => $anime->id, + 'permission_key' => $key, + 'required_membership' => $value, + ]); + } + } + + return back()->with('success', 'İzinler güncellendi.'); + } + + /** + * POST admin/animes/{anime}/fetch-mal-seasons + * Walks the Jikan sequel chain and fills seasons.mal_id automatically. + */ + public function fetchMalSeasons(Request $request, Anime $anime) + { + // Formdan gelen mal_id varsa önce güncelle + if ($request->filled('mal_id')) { + $anime->update(['mal_id' => $request->input('mal_id')]); + } + + if (!$anime->mal_id) { + return response()->json(['error' => 'MAL ID girilmemiş. MyAnimeList.net\'ten anime sayfasını açıp URL\'deki numarayı gir.'], 422); + } + + $jikan = new JikanService(); + $chain = $jikan->fetchSeasonMalIds($anime->mal_id); + + if (empty($chain)) { + return response()->json(['error' => 'Jikan API\'den veri alınamadı.'], 502); + } + + $seasons = Season::where('anime_id', $anime->id) + ->orderBy('season_number') + ->get(); + + $updated = []; + foreach ($seasons as $index => $season) { + $malId = $chain[$index] ?? null; + if ($malId) { + $season->update(['mal_id' => $malId]); + $updated[] = [ + 'season' => $season->season_number, + 'mal_id' => $malId, + ]; + } + } + + // If anime has more seasons than chain entries, remaining seasons stay null + return response()->json([ + 'success' => true, + 'chain' => $chain, + 'updated' => $updated, + 'message' => count($updated) . ' sezon güncellendi.', + ]); + } + + /** + * POST admin/animes/{anime}/fetch-mal + * Tek bir anime için MAL ID arar ve kaydeder. + */ + public function fetchMalSingle(Anime $anime) + { + try { + $malId = (new JikanService())->searchMalId( + $anime->title, $anime->title_en, $anime->title_jp, $anime->type + ); + if ($malId) { + $anime->update(['mal_id' => $malId]); + $s1 = $anime->seasons()->where('season_number', 1)->first(); + if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]); + return response()->json(['found' => true, 'mal_id' => $malId]); + } + return response()->json(['found' => false]); + } catch (\Throwable $e) { + return response()->json(['found' => false, 'error' => $e->getMessage()], 500); + } + } + + public function bulkDestroy(Request $request) + { + if ($request->boolean('all')) { + $query = Anime::query(); + $f = $request->input('filters', []); + if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%'); + if (!empty($f['status'])) $query->where('status', $f['status']); + if (!empty($f['type'])) $query->where('type', $f['type']); + $animes = $query->get(); + } else { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $animes = Anime::whereIn('id', $request->ids)->get(); + } + + $sampleUrls = []; + foreach ($animes as $anime) { + $url = $anime->episodes()->whereNotNull('video_url')->value('video_url'); + if ($url) $sampleUrls[] = $url; + $anime->delete(); + } + + dispatch(function () use ($sampleUrls) { + foreach ($sampleUrls as $url) { + \App\Services\BunnyCdnStorage::deleteAnimeFolder($url); + } + })->afterResponse(); + + return response()->json(['success' => true, 'deleted' => count($animes)]); + } + + /** + * POST admin/animes/bulk-find-mal + * MAL ID'si olmayan animeleri Jikan title search ile toplu doldurur. + * Her seferinde 1 anime işler (AJAX loop), Jikan rate limit aşılmaz. + */ + public function bulkFindMal(Request $request) + { + $skipIds = $request->input('skip_ids', []); + + $anime = Anime::where(fn($q) => $q->whereNull('mal_id')->orWhere('mal_id', '')) + ->when($skipIds, fn($q) => $q->whereNotIn('id', $skipIds)) + ->orderBy('id') + ->first(); + + if (!$anime) { + return response()->json(['done' => true, 'message' => 'Tüm animelerin MAL ID\'si dolu!']); + } + + $jikan = new JikanService(); + $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type); + + if ($malId) { + $anime->update(['mal_id' => $malId]); + + // S1 için season.mal_id de doldur + $s1 = $anime->seasons()->where('season_number', 1)->first(); + if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]); + + return response()->json([ + 'done' => false, + 'found' => true, + 'anime' => $anime->title, + 'mal_id' => $malId, + 'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count(), + ]); + } + + // Bulunamadı — bir sonrakine geç (geçici olarak dummy değer koy, sonra temizle) + return response()->json([ + 'done' => false, + 'found' => false, + 'anime' => $anime->title, + 'mal_id' => null, + 'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count() - 1, + 'skipped_id' => $anime->id, + ]); + } +} diff --git a/app/Http/Controllers/Admin/AnimeRequestController.php b/app/Http/Controllers/Admin/AnimeRequestController.php new file mode 100644 index 0000000..43fa471 --- /dev/null +++ b/app/Http/Controllers/Admin/AnimeRequestController.php @@ -0,0 +1,45 @@ +input('status', 'pending'); + + $requests = AnimeRequest::with('user:id,name,email') + ->when($status !== 'all', fn($q) => $q->where('status', $status)) + ->orderByDesc('vote_count') + ->orderByDesc('created_at') + ->paginate(30); + + $counts = AnimeRequest::selectRaw('status, COUNT(*) as cnt') + ->groupBy('status') + ->pluck('cnt', 'status'); + + return view('admin.anime-requests.index', compact('requests', 'counts', 'status')); + } + + public function update(Request $request, AnimeRequest $animeRequest) + { + $data = $request->validate([ + 'status' => 'required|in:pending,approved,rejected,added', + 'admin_note' => 'nullable|string|max:500', + ]); + + $animeRequest->update($data); + + return back()->with('success', 'İstek güncellendi.'); + } + + public function destroy(AnimeRequest $animeRequest) + { + $animeRequest->delete(); + return back()->with('success', 'İstek silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php new file mode 100644 index 0000000..22bdeb7 --- /dev/null +++ b/app/Http/Controllers/Admin/AuthController.php @@ -0,0 +1,43 @@ +isAdmin()) { + return redirect()->route('admin.dashboard'); + } + return view('admin.auth.login'); + } + + public function login(Request $request) + { + $request->validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if (Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) { + if (!Auth::user()->isAdmin() && !Auth::user()->isModerator()) { + Auth::logout(); + return back()->withErrors(['email' => 'Bu hesabın yönetici yetkisi yok.']); + } + return redirect()->route('admin.dashboard'); + } + + return back()->withErrors(['email' => 'E-posta veya şifre hatalı.']); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + return redirect()->route('admin.login'); + } +} diff --git a/app/Http/Controllers/Admin/BannerController.php b/app/Http/Controllers/Admin/BannerController.php new file mode 100644 index 0000000..1c5b3d1 --- /dev/null +++ b/app/Http/Controllers/Admin/BannerController.php @@ -0,0 +1,65 @@ +route('admin.banners.index'); } + public function show(Banner $banner) { return redirect()->route('admin.banners.index'); } + public function edit(Banner $banner) { return redirect()->route('admin.banners.index'); } + + public function index() + { + $banners = Banner::orderBy('sort_order')->get(); + return view('admin.banners.index', compact('banners')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'link' => 'nullable|url', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]); + + if ($request->hasFile('image')) { + $data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner'); + } else { + return back()->withErrors(['image' => 'Görsel zorunludur.']); + } + + $data['is_active'] = $request->boolean('is_active'); + Banner::create($data); + return back()->with('success', 'Banner eklendi.'); + } + + public function update(Request $request, Banner $banner) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'link' => 'nullable|url', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]); + + if ($request->hasFile('image')) { + $data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner'); + } + + $data['is_active'] = $request->boolean('is_active'); + $banner->update($data); + return back()->with('success', 'Banner güncellendi.'); + } + + public function destroy(Banner $banner) + { + $banner->delete(); + return back()->with('success', 'Banner silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/BlogController.php b/app/Http/Controllers/Admin/BlogController.php new file mode 100644 index 0000000..6dde35f --- /dev/null +++ b/app/Http/Controllers/Admin/BlogController.php @@ -0,0 +1,156 @@ +get('q'); + $posts = BlogPost::with('anime') + ->when($q, fn($query) => $query->where('title', 'like', "%{$q}%")) + ->orderByDesc('created_at') + ->paginate(20); + + $stats = [ + 'total' => BlogPost::count(), + 'published' => BlogPost::where('status', 'published')->count(), + 'draft' => BlogPost::where('status', 'draft')->count(), + 'ai' => BlogPost::where('ai_generated', true)->count(), + ]; + + return view('admin.blog.index', compact('posts', 'stats', 'q')); + } + + public function create() + { + $animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']); + $post = new BlogPost(); + return view('admin.blog.edit', compact('post', 'animes')); + } + + public function store(Request $request) + { + $data = $this->validated($request); + $data['slug'] = BlogPost::generateSlug($data['title']); + $data['published_at'] = $data['status'] === 'published' ? now() : null; + BlogPost::create($data); + return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı oluşturuldu.'); + } + + public function edit(BlogPost $blog) + { + $animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']); + return view('admin.blog.edit', compact('blog', 'animes')); + } + + public function update(Request $request, BlogPost $blog) + { + $data = $this->validated($request); + if ($data['status'] === 'published' && !$blog->published_at) { + $data['published_at'] = now(); + } + $blog->update($data); + return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı güncellendi.'); + } + + public function destroy(BlogPost $blog) + { + $blog->delete(); + return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı silindi.'); + } + + public function generateAi(Request $request, DeepSeekService $deepseek) + { + $request->validate(['anime_id' => 'required|exists:animes,id']); + + if (!$deepseek->isConfigured()) { + return response()->json(['error' => 'DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'], 422); + } + + set_time_limit(120); + + $anime = Anime::with('genres')->findOrFail($request->anime_id); + $genreIds = $anime->genres->pluck('id'); + $related = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds)) + ->orderByDesc('rating') + ->limit(5) + ->get(['id', 'title', 'slug']) + ->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title]) + ->toArray(); + + $data = $deepseek->generateBlogPost($anime, $related); + + if (!$data || empty($data['content'])) { + return response()->json(['error' => 'AI içerik üretemedi: ' . $deepseek->lastError], 422); + } + + $content = preg_replace_callback( + '/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/', + function ($m) { + $slug = trim($m[1]); + $label = trim($m[2]); + try { + return '' . $label . ''; + } catch (\Exception $e) { + return $label; + } + }, + $data['content'] + ); + + $linkedIds = []; + if (!empty($data['linked_slugs'])) { + $linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray(); + } + + return response()->json([ + 'title' => $data['title'] ?? '', + 'excerpt' => $data['excerpt'] ?? '', + 'content' => $content, + 'focus_keyword' => $data['focus_keyword'] ?? $anime->title, + 'meta_description' => $data['meta_description'] ?? '', + 'faq' => $data['faq'] ?? [], + 'linked_anime_ids' => $linkedIds, + ]); + } + + public function bulkGenerate(Request $request) + { + $count = min(5, (int) $request->get('count', 3)); + set_time_limit(300); + try { + \Artisan::call('animexe:generate-blogs', ['--count' => $count, '--force' => false]); + $output = \Artisan::output(); + return redirect()->route('admin.blog.index')->with('success', 'AI blog üretimi tamamlandı: ' . trim($output)); + } catch (\Throwable $e) { + return redirect()->route('admin.blog.index')->with('error', 'Hata: ' . $e->getMessage()); + } + } + + private function validated(Request $request): array + { + return $request->validate([ + 'title' => 'required|string|max:255', + 'excerpt' => 'nullable|string', + 'content' => 'nullable|string', + 'cover_image' => 'nullable|string|max:500', + 'focus_keyword' => 'nullable|string|max:255', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + 'meta_keywords' => 'nullable|string', + 'status' => 'required|in:draft,published', + 'anime_id' => 'nullable|exists:animes,id', + 'reading_time' => 'nullable|integer|min:1|max:60', + ]); + } +} diff --git a/app/Http/Controllers/Admin/CommentController.php b/app/Http/Controllers/Admin/CommentController.php new file mode 100644 index 0000000..aac2c2a --- /dev/null +++ b/app/Http/Controllers/Admin/CommentController.php @@ -0,0 +1,82 @@ + fn(MorphTo $m) => $m->constrain([ + \App\Models\Episode::class => fn($q) => $q->with('season.anime'), + \App\Models\Anime::class => fn($q) => $q, + ]), + ])->latest(); + + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->search) { + $query->where('content', 'like', '%' . $request->search . '%'); + } + if ($request->user_id) { + $query->where('user_id', $request->user_id); + } + + $comments = $query->paginate(30)->withQueryString(); + return view('admin.comments.index', compact('comments')); + } + + public function show(Comment $comment) + { + $comment->load(['user', 'replies.user', 'parent.user']); + return view('admin.comments.show', compact('comment')); + } + + public function approve(Comment $comment) + { + $comment->update(['status' => 'approved']); + return back()->with('success', 'Yorum onaylandı.'); + } + + public function reject(Comment $comment) + { + $comment->update(['status' => 'rejected']); + return back()->with('success', 'Yorum reddedildi.'); + } + + public function pin(Comment $comment) + { + $comment->update(['is_pinned' => !$comment->is_pinned]); + $msg = $comment->is_pinned ? 'Yorum sabitlendi.' : 'Yorum sabit kaldırıldı.'; + return back()->with('success', $msg); + } + + public function destroy(Comment $comment) + { + $comment->delete(); + return back()->with('success', 'Yorum silindi.'); + } + + public function reply(Request $request, Comment $comment) + { + $data = $request->validate(['content' => 'required|string|max:2000']); + + Comment::create([ + 'user_id' => auth()->id(), + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + 'parent_id' => $comment->id, + 'content' => $data['content'], + 'status' => 'approved', + ]); + + return back()->with('success', 'Yanıt gönderildi.'); + } +} diff --git a/app/Http/Controllers/Admin/ContentStatsController.php b/app/Http/Controllers/Admin/ContentStatsController.php new file mode 100644 index 0000000..d1fd8dd --- /dev/null +++ b/app/Http/Controllers/Admin/ContentStatsController.php @@ -0,0 +1,136 @@ +count(); + $totalEpisodes = Episode::count(); + $publishedEps = Episode::where('is_published', true)->count(); + $totalSeasons = Season::count(); + + // ── Son 365 gün — günlük bölüm yükleme (ısı haritası için) ─────────── + $epsByDay = Episode::selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->where('created_at', '>=', now()->subYear()) + ->groupBy('day') + ->orderBy('day') + ->pluck('cnt', 'day'); + + // ── Son 365 gün — günlük anime yükleme ─────────────────────────────── + $animesByDay = Anime::selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->where('created_at', '>=', now()->subYear()) + ->groupBy('day') + ->orderBy('day') + ->pluck('cnt', 'day'); + + // ── Son 90 gün trend (chart için) ───────────────────────────────────── + $from90 = now()->subDays(89)->startOfDay(); + $trendLabels = []; + $epTrendData = []; + $animeTrendData = []; + $cur = clone $from90; + while ($cur->lte(now())) { + $key = $cur->format('Y-m-d'); + $trendLabels[] = $cur->format('d M'); + $epTrendData[] = (int)($epsByDay[$key] ?? 0); + $animeTrendData[] = (int)($animesByDay[$key] ?? 0); + $cur->addDay(); + } + + // ── Saatlik yükleme dağılımı (tüm zamanlar) ────────────────────────── + $hourlyEps = Episode::selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt') + ->groupBy('hour') + ->pluck('cnt', 'hour'); + $hourlyEpsData = array_map(fn($h) => (int)($hourlyEps[$h] ?? 0), range(0, 23)); + + // ── Haftanın günlerine göre dağılım ─────────────────────────────────── + $weekdayEps = Episode::selectRaw('DAYOFWEEK(created_at) as dow, COUNT(*) as cnt') + ->groupBy('dow') + ->pluck('cnt', 'dow'); + // MySQL DAYOFWEEK: 1=Pazar, 2=Pazartesi, ..., 7=Cumartesi + $weekdayLabels = ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt']; + $weekdayData = array_map(fn($d) => (int)($weekdayEps[$d] ?? 0), range(1, 7)); + + // ── Aylık dağılım (son 24 ay) ───────────────────────────────────────── + $monthlyEps = Episode::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as mon, COUNT(*) as cnt') + ->where('created_at', '>=', now()->subMonths(24)) + ->groupBy('mon') + ->orderBy('mon') + ->pluck('cnt', 'mon'); + + $monthLabels = []; + $monthData = []; + $mCur = now()->subMonths(23)->startOfMonth(); + while ($mCur->lte(now())) { + $key = $mCur->format('Y-m'); + $monthLabels[] = $mCur->format('M y'); + $monthData[] = (int)($monthlyEps[$key] ?? 0); + $mCur->addMonth(); + } + + // ── Top 10 en fazla bölüm olan anime ────────────────────────────────── + $topByEpisodes = Anime::withCount('episodes') + ->orderByDesc('episodes_count') + ->limit(10) + ->get(['id', 'title', 'slug', 'cover_image', 'status', 'type']); + + // ── Son eklenen 20 bölüm ─────────────────────────────────────────────── + $recentEpisodes = Episode::with(['anime:id,title,slug', 'season:id,season_number']) + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + // ── Son eklenen 10 anime ─────────────────────────────────────────────── + $recentAnimes = Anime::orderByDesc('created_at') + ->limit(10) + ->get(['id', 'title', 'slug', 'cover_image', 'type', 'status', 'is_published', 'created_at']); + + // ── Isı haritası verisi (52 hafta × 7 gün) ──────────────────────────── + $heatStart = now()->subWeeks(51)->startOfWeek(\Carbon\Carbon::MONDAY); + $heatData = []; + for ($w = 0; $w < 52; $w++) { + $week = []; + for ($d = 0; $d < 7; $d++) { + $day = $heatStart->copy()->addDays($w * 7 + $d); + $key = $day->format('Y-m-d'); + $week[] = [ + 'date' => $key, + 'cnt' => (int)($epsByDay[$key] ?? 0), + ]; + } + $heatData[] = $week; + } + + // ── Tür bazlı bölüm sayısı ──────────────────────────────────────────── + $genreEpStats = DB::table('anime_genre') + ->join('genres', 'genres.id', '=', 'anime_genre.genre_id') + ->join('episodes', 'episodes.anime_id', '=', 'anime_genre.anime_id') + ->select('genres.name', DB::raw('COUNT(episodes.id) as ep_count')) + ->groupBy('genres.id', 'genres.name') + ->orderByDesc('ep_count') + ->limit(12) + ->get(); + + return view('admin.stats.index', compact( + 'totalAnimes', 'publishedAnimes', 'totalEpisodes', 'publishedEps', 'totalSeasons', + 'trendLabels', 'epTrendData', 'animeTrendData', + 'hourlyEpsData', + 'weekdayLabels', 'weekdayData', + 'monthLabels', 'monthData', + 'topByEpisodes', + 'recentEpisodes', 'recentAnimes', + 'heatData', + 'genreEpStats', + )); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..8df42df --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,32 @@ + User::count(), + 'premium_users' => User::where('membership', 'premium')->count(), + 'total_animes' => Anime::count(), + 'total_episodes' => Episode::count(), + 'total_comments' => Comment::count(), + 'pending_comments' => Comment::where('status', 'pending')->count(), + 'active_subs' => Subscription::where('status', 'active')->count(), + ]; + + $recent_users = User::latest()->take(5)->get(); + $recent_comments = Comment::with('user')->latest()->take(5)->get(); + $recent_animes = Anime::latest()->take(5)->get(); + + return view('admin.dashboard', compact('stats', 'recent_users', 'recent_comments', 'recent_animes')); + } +} diff --git a/app/Http/Controllers/Admin/EpisodeController.php b/app/Http/Controllers/Admin/EpisodeController.php new file mode 100644 index 0000000..3c24282 --- /dev/null +++ b/app/Http/Controllers/Admin/EpisodeController.php @@ -0,0 +1,337 @@ +latest(); + + if ($request->anime_id) { + $query->where('anime_id', $request->anime_id); + } + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->search) { + $query->where('title', 'like', '%' . $request->search . '%'); + } + + $episodes = $query->paginate(30)->withQueryString(); + $animes = Anime::orderBy('title')->get(); + + return view('admin.episodes.index', compact('episodes', 'animes')); + } + + public function create(Request $request) + { + $animes = Anime::orderBy('title')->get(); + $seasons = []; + $selectedAnime = null; + + if ($request->anime_id) { + $selectedAnime = Anime::find($request->anime_id); + $seasons = Season::where('anime_id', $request->anime_id)->get(); + } + + $permissions = PermissionSetting::all(); + return view('admin.episodes.create', compact('animes', 'seasons', 'selectedAnime', 'permissions')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season_id' => 'required|exists:seasons,id', + 'episode_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'duration' => 'nullable|integer', + 'source_url' => 'nullable|string', + 'video_url' => 'nullable|string', + 'm3u8_url' => 'nullable|string', + 'source' => 'required|in:bunnycdn,external,direct', + 'is_published' => 'boolean', + ]); + + $data['status'] = $data['is_published'] ? 'published' : 'pending'; + $data['is_published'] = $request->boolean('is_published'); + + if ($request->hasFile('thumbnail')) { + $data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail'); + } + + $episode = Episode::create($data); + + // İzin override'ları + $this->savePermissions($episode, $request->permissions ?? []); + + // Takipçilere bildirim gönder + if ($episode->is_published) { + $this->notifyFollowers($episode); + } + + return redirect()->route('admin.episodes.index', ['anime_id' => $episode->anime_id]) + ->with('success', 'Bölüm eklendi.'); + } + + public function show(Episode $episode) + { + return redirect()->route('admin.episodes.edit', $episode); + } + + public function edit(Episode $episode) + { + $animes = Anime::orderBy('title')->get(); + $seasons = Season::where('anime_id', $episode->anime_id)->get(); + $permissions = PermissionSetting::all(); + $contentPerms = ContentPermission::where('content_type', 'episode') + ->where('content_id', $episode->id) + ->pluck('required_membership', 'permission_key'); + + return view('admin.episodes.edit', compact('episode', 'animes', 'seasons', 'permissions', 'contentPerms')); + } + + public function update(Request $request, Episode $episode) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season_id' => 'required|exists:seasons,id', + 'episode_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'duration' => 'nullable|integer', + 'intro_start' => 'nullable|integer|min:0', + 'intro_end' => 'nullable|integer|min:0', + 'source_url' => 'nullable|string', + 'video_url' => 'nullable|string', + 'm3u8_url' => 'nullable|string', + 'source' => 'required|in:bunnycdn,external,direct', + 'is_published' => 'boolean', + ]); + + $wasPublished = $episode->is_published; + + $data['is_published'] = $request->boolean('is_published'); + $data['status'] = $data['is_published'] ? 'published' : 'pending'; + + if ($request->hasFile('thumbnail')) { + $data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail'); + } + + $episode->update($data); + $this->savePermissions($episode, $request->permissions ?? []); + + // Sadece yeni yayınlandıysa bildirim gönder (zaten yayındaysa tekrar gönderme) + if (!$wasPublished && $episode->is_published) { + $this->notifyFollowers($episode); + } + + return redirect()->route('admin.episodes.edit', $episode)->with('success', 'Bölüm güncellendi.'); + } + + public function destroy(Episode $episode) + { + $animeId = $episode->anime_id; + $videoUrl = $episode->video_url; + $subUrls = $episode->subtitles()->pluck('url')->all(); + + $episode->delete(); + + // CDN'den dosyaları arka planda sil + dispatch(function () use ($videoUrl, $subUrls) { + \App\Services\BunnyCdnStorage::deleteFile($videoUrl); + foreach ($subUrls as $url) { + \App\Services\BunnyCdnStorage::deleteFile($url); + } + })->afterResponse(); + + return redirect()->route('admin.episodes.index', ['anime_id' => $animeId]) + ->with('success', 'Bölüm silindi.'); + } + + private function notifyFollowers(Episode $episode): void + { + $anime = Anime::find($episode->anime_id); + $season = Season::find($episode->season_id); + + if (!$anime) return; + + $followers = \App\Models\AnimeFollow::where('anime_id', $episode->anime_id) + ->join('users', 'users.id', '=', 'anime_follows.user_id') + ->select('users.id as user_id', 'users.fcm_token') + ->get(); + + if ($followers->isEmpty()) return; + + $seasonNum = $season?->season_number ?? 1; + $notifData = json_encode([ + 'anime_id' => $anime->id, + 'anime_title' => $anime->title, + 'anime_slug' => $anime->slug, + 'episode_number' => $episode->episode_number, + 'season_number' => $seasonNum, + 'episode_title' => $episode->title, + ]); + + $rows = []; + $now = now(); + foreach ($followers as $follower) { + $rows[] = [ + 'user_id' => $follower->user_id, + 'type' => 'episode', + 'data' => $notifData, + 'created_at' => $now, + ]; + } + + \App\Models\UserNotification::insert($rows); + + // FCM Push + $fcmTokens = $followers->pluck('fcm_token')->filter()->values()->toArray(); + if (!empty($fcmTokens)) { + $title = $anime->title . ' — Yeni Bölüm!'; + $body = "Sezon {$seasonNum}, {$episode->episode_number}. Bölüm" + . ($episode->title ? ' — ' . $episode->title : '') . ' eklendi.'; + $fcm = new \App\Services\FcmService(); + $fcm->sendToTokens($fcmTokens, $title, $body, [ + 'type' => 'episode', + 'anime_slug' => $anime->slug, + 'season_number' => (string)$seasonNum, + 'episode_number' => (string)$episode->episode_number, + ]); + } + } + + private function savePermissions(Episode $episode, array $permissions): void + { + ContentPermission::where('content_type', 'episode') + ->where('content_id', $episode->id) + ->delete(); + + foreach ($permissions as $key => $value) { + if (in_array($value, ['free', 'premium'])) { + ContentPermission::create([ + 'content_type' => 'episode', + 'content_id' => $episode->id, + 'permission_key' => $key, + 'required_membership' => $value, + ]); + } + } + } + + public function bulkDestroy(Request $request) + { + if ($request->boolean('all')) { + $query = Episode::query(); + $f = $request->input('filters', []); + if (!empty($f['anime_id'])) $query->where('anime_id', $f['anime_id']); + if (!empty($f['status'])) $query->where('status', $f['status']); + if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%'); + $episodes = $query->get(); + } else { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $episodes = Episode::whereIn('id', $request->ids)->get(); + } + + $videoUrls = []; + $subUrls = []; + foreach ($episodes as $ep) { + if ($ep->video_url) $videoUrls[] = $ep->video_url; + foreach ($ep->subtitles()->pluck('url') as $u) $subUrls[] = $u; + $ep->delete(); + } + + dispatch(function () use ($videoUrls, $subUrls) { + foreach ($videoUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url); + foreach ($subUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url); + })->afterResponse(); + + return response()->json(['success' => true, 'deleted' => count($episodes)]); + } + + public function bulkIntro(Request $request) + { + $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season' => 'required|integer|min:0', + 'intro_start' => 'required|integer|min:0', + 'intro_end' => 'required|integer|min:1', + ]); + + $query = Episode::where('anime_id', $request->anime_id); + + if ((int)$request->season > 0) { + $season = \App\Models\Season::where('anime_id', $request->anime_id) + ->where('season_number', $request->season)->first(); + if ($season) $query->where('season_id', $season->id); + } + + $updated = $query->update([ + 'intro_start' => $request->intro_start, + 'intro_end' => $request->intro_end, + ]); + + return response()->json(['ok' => true, 'updated' => $updated]); + } + + // POST /admin/episodes/{episode}/scan-hevc + // Admin panelinden bölümün HLS kaynaklarını sunucu tarafında tarar, HEVC olanları işaretler + public function scanHevc(Episode $episode) + { + $sources = VideoSource::where('episode_id', $episode->id) + ->where('type', 'hls') + ->get(); + + $results = []; + foreach ($sources as $src) { + $isHevc = $this->probeM3u8ForHevc($src->url); + $src->update(['is_hevc' => $isHevc, 'hevc_checked_at' => now()]); + $results[] = [ + 'id' => $src->id, + 'label' => $src->label, + 'quality' => $src->quality, + 'is_hevc' => $isHevc, + ]; + } + + return response()->json(['ok' => true, 'results' => $results]); + } + + private function probeM3u8ForHevc(string $url): bool + { + try { + $response = Http::timeout(8)->withHeaders(['User-Agent' => 'Mozilla/5.0'])->get($url); + if (!$response->ok()) return false; + $text = $response->body(); + + preg_match_all('/#EXT-X-STREAM-INF:([^\n]+)/i', $text, $matches); + if (empty($matches[1])) return false; + + $isHevcCodec = fn($attrs) => (bool) preg_match('/CODECS="[^"]*(?:hev1|hvc1|dvh1)[^"]*"/i', $attrs); + + foreach ($matches[1] as $attrs) { + // CODECS tag yoksa bilinmiyor — H.264 uyumlu say, HEVC değil + if (!str_contains(strtoupper($attrs), 'CODECS=')) return false; + if (!$isHevcCodec($attrs)) return false; + } + + return true; // tüm stream'ler HEVC + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Http/Controllers/Admin/GenreController.php b/app/Http/Controllers/Admin/GenreController.php new file mode 100644 index 0000000..05ba70b --- /dev/null +++ b/app/Http/Controllers/Admin/GenreController.php @@ -0,0 +1,46 @@ +get(); + return view('admin.genres.index', compact('genres')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'nullable|string|max:7', + ]); + $data['slug'] = Str::slug($data['name']); + Genre::create($data); + return back()->with('success', 'Tür eklendi.'); + } + + public function update(Request $request, Genre $genre) + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'nullable|string|max:7', + 'is_active' => 'boolean', + ]); + $data['is_active'] = $request->boolean('is_active'); + $genre->update($data); + return back()->with('success', 'Tür güncellendi.'); + } + + public function destroy(Genre $genre) + { + $genre->delete(); + return back()->with('success', 'Tür silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/HealthController.php b/app/Http/Controllers/Admin/HealthController.php new file mode 100644 index 0000000..519a209 --- /dev/null +++ b/app/Http/Controllers/Admin/HealthController.php @@ -0,0 +1,227 @@ +whereNotNull('mal_id') + ->where('mal_id', '>', 0) + ->select('mal_id', DB::raw('COUNT(*) as cnt')) + ->groupBy('mal_id') + ->having('cnt', '>', 1) + ->get() + ->map(function ($row) { + $animes = Anime::where('mal_id', $row->mal_id) + ->withCount('episodes') + ->get(['id', 'title', 'slug', 'mal_id', 'created_at']); + return ['mal_id' => $row->mal_id, 'animes' => $animes]; + }); + + // 2. Karışık kaynak — aynı anime içinde hem animecix hem anizium bölüm var + $mixedSources = DB::table('episodes') + ->whereIn('source', ['anizium', 'animecix']) + ->whereNotNull('anime_id') + ->select('anime_id', 'source', DB::raw('COUNT(*) as cnt')) + ->groupBy('anime_id', 'source') + ->get() + ->groupBy('anime_id') + ->filter(fn($group) => $group->pluck('source')->unique()->count() > 1) + ->map(function ($group) { + $anime = Anime::find($group->first()->anime_id, ['id', 'title', 'slug']); + if (!$anime) return null; + $sources = $group->mapWithKeys(fn($r) => [$r->source => $r->cnt]); + return ['anime' => $anime, 'sources' => $sources]; + }) + ->filter() + ->values(); + + // 3. Eksik bölümler — episode_count > gerçek bölüm sayısı + $missingEpisodes = Anime::whereNotNull('episode_count') + ->where('episode_count', '>', 0) + ->withCount('episodes') + ->get(['id', 'title', 'slug', 'episode_count']) + ->filter(fn($a) => $a->episodes_count < $a->episode_count) + ->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'expected' => $a->episode_count, + 'actual' => $a->episodes_count, + 'missing' => $a->episode_count - $a->episodes_count, + ]) + ->sortByDesc('missing') + ->values(); + + // 4. Harici CDN bölümler — BunnyCDN'e taşınmamış, Anizium CDN'de kalan + $externalCount = Episode::whereNull('video_url') + ->where(function ($q) { + $q->where('m3u8_url', 'like', '%aniziumserver%') + ->orWhere('m3u8_url', 'like', '%anizium%'); + }) + ->count(); + + $externalSample = Episode::whereNull('video_url') + ->where(function ($q) { + $q->where('m3u8_url', 'like', '%aniziumserver%') + ->orWhere('m3u8_url', 'like', '%anizium%'); + }) + ->with('anime:id,title,slug') + ->select('id', 'anime_id', 'season_id', 'episode_number', 'm3u8_url', 'source') + ->orderByDesc('id') + ->limit(100) + ->get(); + + // 5. Sıfır bölümlü animeler + $zeroEpisodeAnimes = Anime::whereDoesntHave('episodes') + ->get(['id', 'title', 'slug', 'created_at']); + + return view('admin.health.index', compact( + 'malDuplicates', + 'mixedSources', + 'missingEpisodes', + 'externalCount', + 'externalSample', + 'zeroEpisodeAnimes' + )); + } + + // ── Sistem Temizliği ───────────────────────────────────────────────────── + + /** + * Depolama istatistiklerini döndür — inode tüketimini gösterir. + */ + public function storageStats() + { + $dirs = [ + 'seg_cache' => storage_path('app/seg_cache'), + 'cache_data' => storage_path('framework/cache/data'), + 'sessions' => storage_path('framework/sessions'), + 'views' => storage_path('framework/views'), + 'logs' => storage_path('logs'), + 'app_public' => storage_path('app/public'), + ]; + + $stats = []; + foreach ($dirs as $key => $path) { + if (!is_dir($path)) { + $stats[$key] = ['count' => 0, 'size' => 0, 'path' => $path]; + continue; + } + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)); + $count = 0; + $size = 0; + foreach ($files as $f) { + $count++; + $size += $f->getSize(); + } + $stats[$key] = ['count' => $count, 'size' => $size, 'path' => $path]; + } + + return response()->json(['stats' => $stats, 'total_files' => array_sum(array_column($stats, 'count'))]); + } + + /** + * Belirtilen depolama dizinini temizle. + */ + public function cleanupStorage(Request $request) + { + $target = $request->input('target'); + $allowed = [ + 'seg_cache' => storage_path('app/seg_cache'), + 'cache_data' => storage_path('framework/cache/data'), + 'sessions' => storage_path('framework/sessions'), + 'views' => storage_path('framework/views'), + 'old_logs' => storage_path('logs'), + ]; + + if (!array_key_exists($target, $allowed)) { + return response()->json(['error' => 'Geçersiz hedef.'], 422); + } + + $path = $allowed[$target]; + $deleted = 0; + + if (!is_dir($path)) { + return response()->json(['ok' => true, 'deleted' => 0, 'message' => 'Dizin yok.']); + } + + if ($target === 'old_logs') { + // Logları tamamen silme — sadece 7 günden eskilerini sil + foreach (glob($path . '/*.log') ?: [] as $f) { + if (filemtime($f) < time() - 604800) { // 7 gün + @unlink($f); + $deleted++; + } + } + // Laravel her gün yeni log açar, bugünküne dokunma + } else { + // Diğer dizinler: tümünü temizle + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $f) { + if ($f->isFile()) { + @unlink($f->getRealPath()); + $deleted++; + } elseif ($f->isDir()) { + @rmdir($f->getRealPath()); + } + } + } + + // Laravel cache'i PHP seviyesinde de temizle + if ($target === 'cache_data') { + try { \Illuminate\Support\Facades\Cache::flush(); } catch (\Throwable) {} + } + + return response()->json([ + 'ok' => true, + 'deleted' => $deleted, + 'message' => "{$deleted} dosya silindi.", + ]); + } + + /** + * Session driver bilgisi + önerisi. + */ + public function sessionInfo() + { + $driver = config('session.driver', 'file'); + $sessionPath = storage_path('framework/sessions'); + $sessionCount = is_dir($sessionPath) ? count(glob($sessionPath . '/*') ?: []) : 0; + + return response()->json([ + 'driver' => $driver, + 'session_files' => $sessionCount, + 'recommendation'=> $driver === 'file' + ? 'SESSION_DRIVER=database veya cookie kullanmanız önerilir (inode tasarrufu).' + : 'Session sürücüsü inode-dostu.', + ]); + } + + public function deleteAnime(Request $request, Anime $anime) + { + $title = $anime->title; + $anime->delete(); + return back()->with('success', "\"$title\" silindi."); + } + + public function deleteSourceEpisodes(Request $request, Anime $anime) + { + $source = $request->validate(['source' => 'required|in:anizium,animecix'])['source']; + $count = Episode::where('anime_id', $anime->id)->where('source', $source)->count(); + Episode::where('anime_id', $anime->id)->where('source', $source)->delete(); + return back()->with('success', "$anime->title — $source kaynağından $count bölüm silindi."); + } +} diff --git a/app/Http/Controllers/Admin/ImportController.php b/app/Http/Controllers/Admin/ImportController.php new file mode 100644 index 0000000..68851b3 --- /dev/null +++ b/app/Http/Controllers/Admin/ImportController.php @@ -0,0 +1,375 @@ +paginate(20); + + // Araçlar paneli için istatistikler + $stats = [ + 'total_animes' => \App\Models\Anime::where('is_published', true)->count(), + 'anizium_done' => ImportJob::where('source', 'anizium')->where('status', 'done')->count(), + 'animecix_done' => ImportJob::where('source', 'animecix')->where('status', 'done')->count(), + 'video_sources_total' => VideoSource::count(), + 'anizium_sources' => VideoSource::where('source', 'anizium')->count(), + 'animecix_sources' => VideoSource::where('source', 'animecix')->count(), + 'subtitle_mismatch' => $this->countSubtitleMismatch(), + ]; + + return view('admin.import.index', compact('jobs', 'stats')); + } + + public function store(Request $request) + { + $request->validate([ + 'source_url' => 'required|url|max:500', + 'cdn_id' => 'nullable|string|max:50', + 'anime_title' => 'nullable|string|max:255', + 'season_ranges' => 'nullable|array', + 'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1', + ]); + + $watchId = null; + if ($request->source_url) { + preg_match('/\/(?:anime|watch)\/(\d+)/', $request->source_url, $m); + $watchId = $m[1] ?? null; + } + + $ranges = null; + if ($request->filled('season_ranges')) { + $ranges = []; + foreach ($request->season_ranges as $r) { + if (empty($r['season']) || empty($r['from']) || empty($r['to'])) continue; + $from = (int) $r['from']; + $to = (int) $r['to']; + if ($from > $to) [$from, $to] = [$to, $from]; + $ranges[] = ['season' => (int)$r['season'], 'from' => $from, 'to' => $to]; + } + if (empty($ranges)) $ranges = null; + } + + $job = ImportJob::create([ + 'source_url' => $request->source_url, + 'watch_id' => $watchId, + 'cdn_id' => $request->cdn_id ? trim($request->cdn_id) : null, + 'anime_title' => $request->anime_title, + 'season_ranges' => $ranges, + 'status' => 'pending', + ]); + + return redirect()->route('admin.import.show', $job) + ->with('success', "Import job #{$job->id} oluşturuldu. Python script'i başlatın."); + } + + public function show(ImportJob $import) + { + return view('admin.import.show', compact('import')); + } + + public function destroy(ImportJob $import) + { + $import->delete(); + return redirect()->route('admin.import.index')->with('success', 'Job silindi.'); + } + + public function destroyFailed() + { + $count = ImportJob::where('status', 'failed')->count(); + ImportJob::where('status', 'failed')->delete(); + return redirect()->route('admin.import.index')->with('success', "{$count} hatalı job silindi."); + } + + public function destroyPending() + { + $count = ImportJob::where('status', 'pending')->count(); + ImportJob::where('status', 'pending')->delete(); + return redirect()->route('admin.import.index')->with('success', "{$count} bekleyen job silindi."); + } + + public function destroyStuck() + { + // fetching/downloading/uploading ama 2 saatten fazladır güncellenmemiş = takılı kalmış + $cutoff = now()->subHours(2); + $count = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->where('updated_at', '<', $cutoff) + ->count(); + ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->where('updated_at', '<', $cutoff) + ->delete(); + return redirect()->route('admin.import.index')->with('success', "{$count} takılı kalmış job silindi."); + } + + public function bulkCounts() + { + $cutoff = now()->subHours(2); + return response()->json([ + 'failed' => ImportJob::where('status', 'failed')->count(), + 'pending' => ImportJob::where('status', 'pending')->count(), + 'stuck' => ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->where('updated_at', '<', $cutoff) + ->count(), + ]); + } + + public function destroyByStatus(Request $request) + { + $statuses = $request->input('statuses', []); + $hours = (int) $request->input('stuck_hours', 2); + + $allowed = ['pending', 'failed', 'fetching', 'downloading', 'uploading']; + $statuses = array_intersect($statuses, $allowed); + + if (empty($statuses)) { + return response()->json(['ok' => false, 'message' => 'Geçerli status seçilmedi.'], 422); + } + + $query = ImportJob::whereIn('status', $statuses); + + // Aktif statüler için sadece belirtilen saatten eskilerini sil + $activeStatuses = array_intersect($statuses, ['fetching', 'downloading', 'uploading']); + if (!empty($activeStatuses) && count($activeStatuses) === count($statuses)) { + $query->where('updated_at', '<', now()->subHours($hours)); + } + + $count = $query->count(); + $query->delete(); + + return response()->json(['ok' => true, 'deleted' => $count]); + } + + // ── ARAÇLAR: Terminal gerektirmez, admin panelden çalışır ───────────────── + + /** + * Altyazı uyuşmazlığı düzelt (Anizium episode-1 cache bug). + * Subtitle URL'sindeki name=s1_b1_XX yanlış bölümü işaret edenleri siler. + */ + public function fixSubtitles(Request $request) + { + $dryRun = $request->boolean('dry_run', false); + $animeId = $request->input('anime_id'); + + $query = Subtitle::query() + ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('subtitles.url') + ->where('subtitles.url', 'like', '%anizium%') + ->select( + 'subtitles.id as subtitle_id', + 'subtitles.language', + 'subtitles.url', + 'seasons.season_number', + 'episodes.episode_number', + 'episodes.anime_id', + ); + + if ($animeId) { + $query->where('episodes.anime_id', (int) $animeId); + } + + $subtitles = $query->get(); + $mismatchIds = []; + $details = []; + + foreach ($subtitles as $sub) { + $parsed = parse_url($sub->url); + if (!isset($parsed['query'])) continue; + parse_str($parsed['query'], $params); + $name = $params['name'] ?? ''; + if (!$name) continue; + + $expectedPrefix = "s{$sub->season_number}_b{$sub->episode_number}_"; + if (!str_starts_with($name, $expectedPrefix)) { + $mismatchIds[] = $sub->subtitle_id; + $details[] = [ + 'anime_id' => $sub->anime_id, + 'season' => $sub->season_number, + 'episode' => $sub->episode_number, + 'lang' => $sub->language, + 'name' => $name, + 'expected' => $expectedPrefix . $sub->language, + ]; + } + } + + $deleted = 0; + if (!$dryRun && !empty($mismatchIds)) { + $deleted = Subtitle::whereIn('id', $mismatchIds)->delete(); + } + + return response()->json([ + 'ok' => true, + 'dry_run' => $dryRun, + 'checked' => $subtitles->count(), + 'mismatch' => count($mismatchIds), + 'deleted' => $deleted, + 'details' => array_slice($details, 0, 30), + ]); + } + + /** + * Anizium done job'larını yeniden pending yap (yeni video_sources eklemek için). + * Her job'ın done_episodes sıfırlanır; Anizium bot yeniden çalışınca + * doneEpisodes() artık source='anizium' kontrolü yaptığından + * sadece video_sources'ta anizium kaydı OLMAYAN bölümleri yeniden işler. + */ + public function requeueAnizium(Request $request) + { + $limit = (int) $request->input('limit', 50); + $animeId = $request->input('anime_id'); + + $query = ImportJob::where('source', 'anizium') + ->where('status', 'done') + ->whereNotNull('watch_id') + ->latest(); + + if ($animeId) { + $query->where('anime_id', (int) $animeId); + } + + $jobs = $query->limit($limit)->get(); + $requeued = 0; + + foreach ($jobs as $job) { + // Zaten pending/işleniyor olan var mı? + $active = ImportJob::where('watch_id', $job->watch_id) + ->where('source', 'anizium') + ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading']) + ->exists(); + + if (!$active) { + $job->update([ + 'status' => 'pending', + 'done_episodes'=> 0, + 'error_log' => null, + 'current_step' => 'Çapraz re-import — video_sources yenileme', + ]); + $requeued++; + } + } + + return response()->json([ + 'ok' => true, + 'checked' => $jobs->count(), + 'requeued' => $requeued, + ]); + } + + /** + * AnimeCix done job'larını yeniden pending yap. + */ + public function requeueAnimecix(Request $request) + { + $limit = (int) $request->input('limit', 50); + $animeId = $request->input('anime_id'); + + $query = ImportJob::where('source', 'animecix') + ->where('status', 'done') + ->whereNotNull('animecix_title_id') + ->latest(); + + if ($animeId) { + $query->where('anime_id', (int) $animeId); + } + + $jobs = $query->limit($limit)->get(); + $requeued = 0; + + foreach ($jobs as $job) { + $active = ImportJob::where('animecix_title_id', $job->animecix_title_id) + ->where('source', 'animecix') + ->whereIn('status', ['pending', 'fetching']) + ->exists(); + + if (!$active) { + $job->update([ + 'status' => 'pending', + 'done_episodes'=> 0, + 'error_log' => null, + 'current_step' => 'Çapraz re-import — video_sources yenileme', + ]); + $requeued++; + } + } + + return response()->json([ + 'ok' => true, + 'checked' => $jobs->count(), + 'requeued' => $requeued, + ]); + } + + /** + * video_sources istatistikleri (AJAX için). + */ + public function sourceStats() + { + $animeCount = \App\Models\Anime::where('is_published', true)->count(); + + $episodesWithBoth = DB::table('episodes') + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) + ->where('is_published', true) + ->count(); + + $episodesOnlyAnizium = DB::table('episodes') + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) + ->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) + ->where('is_published', true) + ->count(); + + $episodesOnlyAnimecix = DB::table('episodes') + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) + ->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) + ->where('is_published', true) + ->count(); + + return response()->json([ + 'anime_count' => $animeCount, + 'episodes_with_both' => $episodesWithBoth, + 'episodes_only_anizium' => $episodesOnlyAnizium, + 'episodes_only_animecix' => $episodesOnlyAnimecix, + 'subtitle_mismatch' => $this->countSubtitleMismatch(), + 'anizium_pending_jobs' => ImportJob::where('source', 'anizium')->where('status', 'pending')->count(), + 'animecix_pending_jobs' => ImportJob::where('source', 'animecix')->where('status', 'pending')->count(), + ]); + } + + // ── Yardımcı ───────────────────────────────────────────────────────────── + + private function countSubtitleMismatch(): int + { + $rows = Subtitle::query() + ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('subtitles.url') + ->where('subtitles.url', 'like', '%anizium%') + ->select('subtitles.url', 'seasons.season_number', 'episodes.episode_number') + ->get(); + + $count = 0; + foreach ($rows as $r) { + $parsed = parse_url($r->url); + if (!isset($parsed['query'])) continue; + parse_str($parsed['query'], $params); + $name = $params['name'] ?? ''; + if ($name && !str_starts_with($name, "s{$r->season_number}_b{$r->episode_number}_")) { + $count++; + } + } + return $count; + } +} diff --git a/app/Http/Controllers/Admin/MobileAppController.php b/app/Http/Controllers/Admin/MobileAppController.php new file mode 100644 index 0000000..fa053fd --- /dev/null +++ b/app/Http/Controllers/Admin/MobileAppController.php @@ -0,0 +1,69 @@ + Setting::get('mobile_min_version', '1.0.0'), + 'mobile_current_version' => Setting::get('mobile_current_version', '1.0.0'), + 'mobile_apk_url' => Setting::get('mobile_apk_url', ''), + 'mobile_maintenance_mode' => Setting::get('mobile_maintenance_mode', '0'), + 'mobile_maintenance_message'=> Setting::get('mobile_maintenance_message', 'Uygulama şu anda bakımda. Lütfen daha sonra tekrar deneyin.'), + 'mobile_force_update_msg' => Setting::get('mobile_force_update_msg', 'Uygulamayı kullanmaya devam etmek için lütfen güncelleyin.'), + ]; + + // Stats + $stats = [ + 'total_users' => User::count(), + 'fcm_tokens' => User::whereNotNull('fcm_token')->where('fcm_token', '!=', '')->count(), + 'notifications_sent'=> UserNotification::count(), + 'notifs_today' => UserNotification::whereDate('created_at', today())->count(), + 'notifs_unread' => UserNotification::whereNull('read_at')->count(), + ]; + + // Active users (logged in last 30 days, via tokens) + try { + $stats['active_30d'] = DB::table('personal_access_tokens') + ->where('tokenable_type', User::class) + ->where('last_used_at', '>=', now()->subDays(30)) + ->distinct('tokenable_id') + ->count('tokenable_id'); + } catch (\Throwable $e) { + $stats['active_30d'] = '–'; + } + + return view('admin.mobile.index', compact('settings', 'stats')); + } + + public function update(Request $request) + { + $data = $request->validate([ + 'mobile_min_version' => 'required|string|max:20', + 'mobile_current_version' => 'required|string|max:20', + 'mobile_apk_url' => 'nullable|url|max:500', + 'mobile_maintenance_mode' => 'boolean', + 'mobile_maintenance_message' => 'required|string|max:300', + 'mobile_force_update_msg' => 'required|string|max:300', + ]); + + // Checkbox absent = unchecked → force '0' + $data['mobile_maintenance_mode'] = $request->boolean('mobile_maintenance_mode') ? '1' : '0'; + + foreach ($data as $key => $value) { + Setting::set($key, $value ?? '', 'mobile'); + } + + return back()->with('success', 'Mobil uygulama ayarları güncellendi.'); + } +} diff --git a/app/Http/Controllers/Admin/ModeratorController.php b/app/Http/Controllers/Admin/ModeratorController.php new file mode 100644 index 0000000..973c948 --- /dev/null +++ b/app/Http/Controllers/Admin/ModeratorController.php @@ -0,0 +1,111 @@ +withCount('moderatorPermissions') + ->with('moderatorPermissions:user_id,permission') + ->orderByDesc('created_at') + ->paginate(20); + + return view('admin.moderators.index', [ + 'moderators' => $moderators, + 'groups' => ModeratorPermission::$groups, + ]); + } + + public function edit(User $user) + { + abort_if($user->isAdmin(), 403); + + $permissions = ModeratorPermission::where('user_id', $user->id) + ->pluck('permission') + ->flip() // key = permission, value = true for fast lookup + ->all(); + + return view('admin.moderators.edit', [ + 'moderator' => $user, + 'groups' => ModeratorPermission::$groups, + 'permissions' => $permissions, + ]); + } + + /** Admin assigns a user the moderator role */ + public function promote(Request $request) + { + $request->validate(['user_id' => 'required|exists:users,id']); + + $user = User::findOrFail($request->user_id); + abort_if($user->isAdmin(), 403, 'Admin kullanıcı düzenlenemez.'); + + $user->update(['role' => 'moderator']); + + return back()->with('success', "{$user->name} moderatör yapıldı."); + } + + /** Remove moderator role */ + public function demote(User $user) + { + abort_if($user->isAdmin(), 403); + $user->update(['role' => 'user']); + ModeratorPermission::where('user_id', $user->id)->delete(); + $user->flushPermCache(); + + return back()->with('success', "{$user->name} moderatörlükten çıkarıldı."); + } + + /** Save permission checkboxes */ + public function savePermissions(Request $request, User $user) + { + abort_if($user->isAdmin(), 403); + abort_if($user->role !== 'moderator', 422, 'Kullanıcı moderatör değil.'); + + $allKeys = ModeratorPermission::allKeys(); + $submitted = array_intersect($request->input('permissions', []), $allKeys); + + // Delete old, insert new + ModeratorPermission::where('user_id', $user->id)->delete(); + foreach ($submitted as $perm) { + ModeratorPermission::create([ + 'user_id' => $user->id, + 'permission' => $perm, + 'granted_by' => auth()->id(), + ]); + } + + $user->flushPermCache(); + + return back()->with('success', 'İzinler kaydedildi. (' . count($submitted) . ' izin aktif)'); + } + + /** Quick permission toggle via AJAX */ + public function togglePermission(Request $request, User $user) + { + abort_if($user->isAdmin(), 403); + $perm = $request->input('permission'); + abort_unless(in_array($perm, ModeratorPermission::allKeys()), 422); + + $existing = ModeratorPermission::where('user_id', $user->id) + ->where('permission', $perm)->first(); + if ($existing) { + $existing->delete(); + $active = false; + } else { + ModeratorPermission::create(['user_id' => $user->id, 'permission' => $perm, 'granted_by' => auth()->id()]); + $active = true; + } + + $user->flushPermCache(); + + return response()->json(['active' => $active]); + } +} diff --git a/app/Http/Controllers/Admin/NotificationController.php b/app/Http/Controllers/Admin/NotificationController.php new file mode 100644 index 0000000..4f34c16 --- /dev/null +++ b/app/Http/Controllers/Admin/NotificationController.php @@ -0,0 +1,89 @@ +orderByDesc('created_at') + ->limit(50) + ->get(); + + $stats = [ + 'total' => UserNotification::count(), + 'unread' => UserNotification::whereNull('read_at')->count(), + 'users' => User::count(), + 'today' => UserNotification::whereDate('created_at', today())->count(), + ]; + + return view('admin.notifications.index', compact('recent', 'stats')); + } + + public function send(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:100', + 'body' => 'required|string|max:500', + 'url' => 'nullable|url|max:300', + 'target' => 'required|in:all,premium,free', + 'icon' => 'nullable|string|max:50', + ]); + + $query = User::query(); + + if ($data['target'] === 'premium') { + $query->where('membership', 'premium') + ->where(fn($q) => $q->whereNull('premium_expires_at')->orWhere('premium_expires_at', '>', now())); + } elseif ($data['target'] === 'free') { + $query->where(fn($q) => $q->where('membership', '!=', 'premium')->orWhere('premium_expires_at', '<=', now())); + } + + $users = $query->select('id', 'fcm_token')->get(); + + if ($users->isEmpty()) { + return back()->with('error', 'Hedef kullanıcı bulunamadı.'); + } + + $notifData = json_encode([ + 'title' => $data['title'], + 'body' => $data['body'], + 'url' => $data['url'] ?? null, + 'icon' => $data['icon'] ?? 'bi-megaphone-fill', + 'admin' => true, + ]); + + $now = now(); + $rows = $users->map(fn($u) => [ + 'user_id' => $u->id, + 'type' => 'admin', + 'data' => $notifData, + 'created_at' => $now, + ])->toArray(); + + // In-app notifications + foreach (array_chunk($rows, 500) as $chunk) { + UserNotification::insert($chunk); + } + + // FCM Push notifications + $fcmTokens = $users->pluck('fcm_token')->filter()->values()->toArray(); + if (!empty($fcmTokens)) { + $fcm = new FcmService(); + $fcm->sendToTokens($fcmTokens, $data['title'], $data['body'], [ + 'type' => 'admin', + 'url' => $data['url'] ?? '', + ]); + } + + return back()->with('success', count($rows) . ' kullanıcıya bildirim gönderildi' . (!empty($fcmTokens) ? ' (' . count($fcmTokens) . ' push)' : '') . '.'); + } +} diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php new file mode 100644 index 0000000..fc151f5 --- /dev/null +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -0,0 +1,29 @@ +permissions ?? []; + + foreach ($permissions as $key => $value) { + if (in_array($value, ['free', 'premium'])) { + PermissionSetting::where('key', $key)->update(['required_membership' => $value]); + } + } + + return back()->with('success', 'Global izinler güncellendi.'); + } +} diff --git a/app/Http/Controllers/Admin/PlanController.php b/app/Http/Controllers/Admin/PlanController.php new file mode 100644 index 0000000..42783df --- /dev/null +++ b/app/Http/Controllers/Admin/PlanController.php @@ -0,0 +1,115 @@ +get(); + return view('admin.plans.index', compact('plans')); + } + + public function create() + { + $allPerks = PremiumFeatures::grouped(); + return view('admin.plans.create', compact('allPerks')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'purchase_link' => 'nullable|url|max:1000', + 'duration_days' => 'required|integer|min:1', + 'trial_days' => 'nullable|integer|min:0', + 'badge_label' => 'nullable|string|max:32', + 'accent_color' => 'nullable|string|max:16', + 'features' => 'nullable|array', + 'features.*' => 'string', + 'perks' => 'nullable|array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'visible_until' => 'nullable|date', + 'sort_order' => 'integer', + ]); + + $data['slug'] = Str::slug($data['name']); + $data['is_active'] = $request->boolean('is_active'); + $data['is_public'] = $request->boolean('is_public', true); + $data['trial_days'] = (int) ($request->input('trial_days', 0)); + $data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null; + $data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null; + $data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null; + $data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null; + $data['features'] = array_values(array_filter($request->features ?? [])); + + $perks = []; + foreach (array_keys(PremiumFeatures::ALL) as $key) { + $perks[$key] = in_array($key, $request->input('perks', [])); + } + $data['perks'] = $perks; + + MembershipPlan::create($data); + return redirect()->route('admin.plans.index')->with('success', 'Plan eklendi.'); + } + + public function edit(MembershipPlan $plan) + { + $allPerks = PremiumFeatures::grouped(); + return view('admin.plans.edit', compact('plan', 'allPerks')); + } + + public function update(Request $request, MembershipPlan $plan) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'purchase_link' => 'nullable|url|max:1000', + 'duration_days' => 'required|integer|min:1', + 'trial_days' => 'nullable|integer|min:0', + 'badge_label' => 'nullable|string|max:32', + 'accent_color' => 'nullable|string|max:16', + 'features' => 'nullable|array', + 'features.*' => 'string', + 'perks' => 'nullable|array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'visible_until' => 'nullable|date', + 'sort_order' => 'integer', + ]); + + $data['is_active'] = $request->boolean('is_active'); + $data['is_public'] = $request->boolean('is_public', true); + $data['trial_days'] = (int) ($request->input('trial_days', 0)); + $data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null; + $data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null; + $data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null; + $data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null; + $data['features'] = array_values(array_filter($request->features ?? [])); + + $perks = []; + foreach (array_keys(PremiumFeatures::ALL) as $key) { + $perks[$key] = in_array($key, $request->input('perks', [])); + } + $data['perks'] = $perks; + + $plan->update($data); + return redirect()->route('admin.plans.index')->with('success', 'Plan güncellendi.'); + } + + public function destroy(MembershipPlan $plan) + { + $plan->delete(); + return redirect()->route('admin.plans.index')->with('success', 'Plan silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/SeasonController.php b/app/Http/Controllers/Admin/SeasonController.php new file mode 100644 index 0000000..7bc4d9e --- /dev/null +++ b/app/Http/Controllers/Admin/SeasonController.php @@ -0,0 +1,59 @@ +route('admin.animes.show', $anime); } + public function create(Anime $anime) { return redirect()->route('admin.animes.show', $anime); } + public function show(Season $season) { return redirect()->route('admin.animes.show', $season->anime_id); } + + public function store(Request $request, Anime $anime) + { + $data = $request->validate([ + 'season_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'is_published' => 'boolean', + ]); + + $data['anime_id'] = $anime->id; + $data['is_published'] = $request->boolean('is_published'); + + Season::create($data); + return redirect()->route('admin.animes.show', $anime)->with('success', 'Sezon eklendi.'); + } + + public function edit(Season $season) + { + return view('admin.seasons.edit', compact('season')); + } + + public function update(Request $request, Season $season) + { + $data = $request->validate([ + 'season_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'is_published' => 'boolean', + ]); + + $data['is_published'] = $request->boolean('is_published'); + $season->update($data); + return redirect()->route('admin.animes.show', $season->anime_id)->with('success', 'Sezon güncellendi.'); + } + + public function destroy(Season $season) + { + $animeId = $season->anime_id; + $season->delete(); + return redirect()->route('admin.animes.show', $animeId)->with('success', 'Sezon silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/SeoController.php b/app/Http/Controllers/Admin/SeoController.php new file mode 100644 index 0000000..46dab2a --- /dev/null +++ b/app/Http/Controllers/Admin/SeoController.php @@ -0,0 +1,971 @@ + 'Animexe', + 'seo_title_template' => '%s — Animexe | Türkçe Anime İzle', + 'seo_home_title' => 'Animexe — Türkçe Anime İzle | Ücretsiz HD', + 'seo_home_description' => 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz ve yüksek kalitede izleyin.', + 'seo_home_keywords' => 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime izle, hd anime, türkçe altyazılı anime, türkçe dublajlı anime', + 'seo_og_image' => '/logo.jpg', + 'seo_twitter_site' => '', + 'seo_facebook_app_id' => '', + 'seo_canonical_domain' => '', + 'seo_google_analytics' => '', + 'seo_gtm_id' => '', + 'seo_gsc_verification' => '', + 'seo_bing_verification' => '', + 'seo_yandex_verification' => '', + 'seo_enable_schema' => '1', + 'seo_enable_breadcrumb' => '1', + 'seo_noindex_search' => '1', + 'seo_noindex_profile' => '1', + 'seo_noindex_watch' => '0', + 'seo_org_logo' => '/logo.jpg', + 'seo_org_twitter' => '', + 'seo_org_facebook' => '', + 'seo_org_instagram' => '', + 'seo_robots_custom' => '', + 'seo_pagespeed_api_key' => '', + 'seo_looker_embed_url' => '', + 'seo_enable_faq_schema' => '1', + 'seo_enable_video_schema' => '1', + ]; + + public function index() + { + $settings = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key')->toArray(); + foreach ($this->defaults as $key => $val) { + if (!array_key_exists($key, $settings)) { + $settings[$key] = $val; + } + } + + $robotsPath = public_path('robots.txt'); + $robotsTxt = File::exists($robotsPath) ? File::get($robotsPath) : ''; + $audit = $this->runAudit(); + + $sitemapStats = [ + 'anime_count' => Anime::where('is_published', true)->count(), + 'genre_count' => Genre::where('is_active', true)->count(), + 'static_count' => 2, + 'last_updated' => Setting::get('seo_sitemap_generated_at', null), + ]; + + // Keyword tracker + $keywords = SeoKeyword::orderBy('keyword')->get(); + + // Redirect manager + $redirects = SeoRedirect::orderByDesc('hits')->paginate(25, ['*'], 'rpage'); + + // Bulk SEO — animelerin SEO verileri (seo_title veya seo_meta_desc eksik olanlar önce) + $animes = Anime::where('is_published', true) + ->orderByRaw('(seo_title IS NULL OR seo_title = "") DESC') + ->orderBy('title') + ->select('id', 'title', 'slug', 'description', 'seo_title', 'seo_meta_desc', 'seo_keywords') + ->paginate(30, ['*'], 'apage'); + + $animeSeoCoverage = [ + 'total' => Anime::where('is_published', true)->count(), + 'has_seo_title'=> Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(), + 'has_seo_desc' => Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(), + ]; + + // Image alt audit — animes with cover + $missingAlt = Anime::where('is_published', true) + ->whereNotNull('cover_image')->where('cover_image', '!=', '') + ->whereNull('title')->count(); // titles serve as alt text, so just check no-title + + // Duplicate descriptions + $dupDesc = DB::table('animes') + ->select('description', DB::raw('COUNT(*) as cnt')) + ->where('is_published', true) + ->whereNotNull('description') + ->where('description', '!=', '') + ->groupBy('description') + ->having('cnt', '>', 1) + ->count(); + + // ── Analytics stats for Google tab ─────────────────────────────────── + $analyticsStats = [ + 'total_anime' => Anime::where('is_published', true)->count(), + 'total_episodes' => class_exists(Episode::class) ? Episode::count() : 0, + 'total_users' => User::count(), + 'total_genres' => Genre::where('is_active', true)->count(), + 'total_comments' => class_exists(Comment::class) ? Comment::count() : 0, + 'total_watchlists' => class_exists(Watchlist::class) ? Watchlist::count() : 0, + 'total_ratings' => class_exists(AnimeRating::class) ? AnimeRating::count() : 0, + 'total_blog_posts' => class_exists(BlogPost::class) ? BlogPost::count() : 0, + 'total_redirects' => SeoRedirect::where('is_active', true)->count(), + 'total_redirect_hits'=> SeoRedirect::sum('hits'), + 'seo_title_pct' => $sitemapStats['anime_count'] > 0 + ? round($animeSeoCoverage['has_seo_title'] / $sitemapStats['anime_count'] * 100) + : 0, + 'seo_desc_pct' => $sitemapStats['anime_count'] > 0 + ? round($animeSeoCoverage['has_seo_desc'] / $sitemapStats['anime_count'] * 100) + : 0, + 'new_anime_this_month' => Anime::where('is_published', true) + ->where('created_at', '>=', now()->startOfMonth())->count(), + 'new_users_this_month' => User::where('created_at', '>=', now()->startOfMonth())->count(), + ]; + + // Integration status + $integrations = [ + 'ga4' => !empty($settings['seo_google_analytics'] ?? ''), + 'gtm' => !empty($settings['seo_gtm_id'] ?? ''), + 'gsc' => !empty($settings['seo_gsc_verification'] ?? ''), + 'bing' => !empty($settings['seo_bing_verification'] ?? ''), + 'yandex' => !empty($settings['seo_yandex_verification'] ?? ''), + ]; + + return view('admin.seo.index', compact( + 'settings', 'robotsTxt', 'audit', 'sitemapStats', + 'keywords', 'redirects', 'animes', 'animeSeoCoverage', 'dupDesc', + 'analyticsStats', 'integrations' + )); + } + + public function update(Request $request) + { + // Tüm alanlar opsiyonel — her tab kendi alanlarını gönderir (partial update) + $rules = [ + 'seo_site_name' => 'nullable|string|max:100', + 'seo_title_template' => 'nullable|string|max:200', + 'seo_home_title' => 'nullable|string|max:200', + 'seo_home_description' => 'nullable|string|max:500', + 'seo_home_keywords' => 'nullable|string|max:500', + 'seo_og_image' => 'nullable|string|max:500', + 'seo_twitter_site' => 'nullable|string|max:100', + 'seo_facebook_app_id' => 'nullable|string|max:100', + 'seo_canonical_domain' => 'nullable|url|max:200', + 'seo_google_analytics' => 'nullable|string|max:50', + 'seo_gtm_id' => 'nullable|string|max:50', + 'seo_gsc_verification' => 'nullable|string|max:200', + 'seo_bing_verification' => 'nullable|string|max:200', + 'seo_yandex_verification' => 'nullable|string|max:200', + 'seo_org_logo' => 'nullable|string|max:500', + 'seo_org_twitter' => 'nullable|string|max:200', + 'seo_org_facebook' => 'nullable|string|max:200', + 'seo_org_instagram' => 'nullable|string|max:200', + 'seo_pagespeed_api_key' => 'nullable|string|max:100', + 'seo_looker_embed_url' => 'nullable|string|max:500', + ]; + + $validated = $request->validate($rules); + + // Checkbox alanları: sadece request'te varsa güncelle + $checkboxes = [ + 'seo_enable_schema', 'seo_enable_breadcrumb', 'seo_noindex_search', + 'seo_noindex_profile', 'seo_noindex_watch', 'seo_enable_faq_schema', 'seo_enable_video_schema', + ]; + foreach ($checkboxes as $key) { + if ($request->has($key) || $request->has('_seo_section')) { + $value = $request->input($key); + $validated[$key] = ($value === '1' || $value === 'on') ? '1' : '0'; + } + } + + // Sadece gönderilen (non-null) alanları kaydet + foreach ($validated as $key => $value) { + if ($value !== null) { + Setting::set($key, $value, 'seo'); + } + } + + cache()->forget('seo_settings'); + + if ($request->wantsJson()) { + return response()->json(['ok' => true, 'message' => 'SEO ayarları kaydedildi.']); + } + return back()->with('success', 'SEO ayarları başarıyla kaydedildi.'); + } + + public function updateRobots(Request $request) + { + $request->validate(['robots_txt' => 'required|string|max:10000']); + File::put(public_path('robots.txt'), $request->input('robots_txt')); + return back()->with('success', 'robots.txt güncellendi.'); + } + + public function pingSearchEngines(Request $request) + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + $sitemapUrl = urlencode($domain . '/sitemap.xml'); + $results = []; + + foreach (['google' => "https://www.google.com/ping?sitemap={$sitemapUrl}", 'bing' => "https://www.bing.com/ping?sitemap={$sitemapUrl}"] as $engine => $url) { + try { + $r = Http::timeout(5)->get($url); + $results[$engine] = $r->successful() ? 'success' : 'error'; + } catch (\Throwable) { + $results[$engine] = 'error'; + } + } + + Setting::set('seo_sitemap_pinged_at', now()->toDateTimeString(), 'seo'); + return back()->with('ping_results', $results)->with('success', 'Arama motorlarına bildirim gönderildi.'); + } + + public function auditJson() + { + return response()->json($this->runAudit()); + } + + // ── Keyword Tracker ─────────────────────────────────────────────────────── + + public function storeKeyword(Request $request) + { + $data = $request->validate([ + 'keyword' => 'required|string|max:255', + 'target_url' => 'nullable|string|max:500', + 'search_volume' => 'nullable|integer|min:0', + 'difficulty' => 'nullable|integer|min:0|max:100', + 'notes' => 'nullable|string|max:1000', + ]); + SeoKeyword::create($data); + return back()->with('success', 'Anahtar kelime eklendi.'); + } + + public function destroyKeyword(SeoKeyword $keyword) + { + $keyword->delete(); + return back()->with('success', 'Anahtar kelime silindi.'); + } + + // ── Redirect Manager ───────────────────────────────────────────────────── + + public function storeRedirect(Request $request) + { + $data = $request->validate([ + 'from_path' => 'required|string|max:500', + 'to_path' => 'required|string|max:500', + 'type' => 'required|in:301,302', + ]); + + $data['from_path'] = '/' . ltrim($data['from_path'], '/'); + + SeoRedirect::updateOrCreate(['from_path' => $data['from_path']], $data); + cache()->forget('seo_redirect_' . md5($data['from_path'])); + return back()->with('success', 'Yönlendirme eklendi/güncellendi.'); + } + + public function destroyRedirect(SeoRedirect $redirect) + { + cache()->forget('seo_redirect_' . md5($redirect->from_path)); + $redirect->delete(); + return back()->with('success', 'Yönlendirme silindi.'); + } + + public function toggleRedirect(SeoRedirect $redirect) + { + $redirect->update(['is_active' => !$redirect->is_active]); + cache()->forget('seo_redirect_' . md5($redirect->from_path)); + return response()->json(['is_active' => $redirect->is_active]); + } + + // ── Bulk Anime SEO ──────────────────────────────────────────────────────── + + public function bulkSaveAnime(Request $request) + { + $data = $request->validate([ + 'animes' => 'required|array', + 'animes.*.id' => 'required|integer|exists:animes,id', + 'animes.*.seo_title' => 'nullable|string|max:100', + 'animes.*.seo_meta_desc' => 'nullable|string|max:320', + 'animes.*.seo_keywords' => 'nullable|string|max:500', + ]); + + foreach ($data['animes'] as $row) { + Anime::where('id', $row['id'])->update([ + 'seo_title' => $row['seo_title'] ?? null, + 'seo_meta_desc' => $row['seo_meta_desc'] ?? null, + 'seo_keywords' => $row['seo_keywords'] ?? null, + ]); + } + + return back()->with('success', count($data['animes']) . ' anime için SEO verileri kaydedildi.'); + } + + public function generateAnimeSeo(Anime $anime) + { + $title = trim($anime->title); + $seoTitle = $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe'; + $seoTitle = mb_substr($seoTitle, 0, 70); + + $desc = $anime->description + ? mb_substr(strip_tags($anime->description), 0, 130) + : ''; + $seoDesc = $desc + ? $desc . ' Animexe\'de Türkçe altyazılı izle.' + : $title . '\'yi Türkçe altyazılı veya dublajlı, ücretsiz ve HD kalitede Animexe\'de izleyin.'; + $seoDesc = mb_substr($seoDesc, 0, 160); + + $keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe, ' . strtolower($title) . ' türkçe altyazılı'; + + $anime->update([ + 'seo_title' => $seoTitle, + 'seo_meta_desc' => $seoDesc, + 'seo_keywords' => $keywords, + ]); + + return response()->json([ + 'seo_title' => $seoTitle, + 'seo_meta_desc' => $seoDesc, + 'seo_keywords' => $keywords, + ]); + } + + public function bulkGenerateAllSeo(Request $request) + { + $animes = Anime::where('is_published', true) + ->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')) + ->get(['id', 'title', 'type', 'description']); + + $count = 0; + foreach ($animes as $anime) { + $title = trim($anime->title); + $seoTitle = mb_substr($title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe', 0, 70); + $desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : ''; + $seoDesc = mb_substr($desc ? $desc . ' Animexe\'de Türkçe izle.' : $title . '\'yi Animexe\'de ücretsiz izleyin.', 0, 160); + $keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı'; + + $anime->update([ + 'seo_title' => $seoTitle, + 'seo_meta_desc' => $seoDesc, + 'seo_keywords' => $keywords, + ]); + $count++; + } + + return response()->json(['generated' => $count]); + } + + /** + * DeepSeek ile toplu AI SEO üretimi — SEO başlığı olmayan animeleri işler. + * Her batch 10 anime, aralarında 1s bekleme (rate limit önlemi). + * İstek başına max 10 anime işler; frontend'den tekrar tekrar çağrılarak tamamlanır. + */ + public function aiBulkGenerateSeo(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $batchSize = min((int)$request->input('batch', 10), 20); + + $animes = Anime::where('is_published', true) + ->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')) + ->with('genres:id,name') + ->limit($batchSize) + ->get(); + + $remaining = Anime::where('is_published', true) + ->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')) + ->count(); + + $done = 0; + $errors = 0; + + foreach ($animes as $anime) { + $result = $ai->generateAnimeSeoMeta($anime); + if ($result) { + $anime->update([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + $done++; + } else { + $errors++; + } + sleep(1); // DeepSeek rate limit + } + + return response()->json([ + 'done' => $done, + 'errors' => $errors, + 'remaining' => max(0, $remaining - $done), + ]); + } + + // ── PageSpeed ───────────────────────────────────────────────────────────── + + public function pagespeedCheck(Request $request) + { + $request->validate(['url' => 'required|url', 'strategy' => 'in:mobile,desktop']); + + $apiKey = Setting::get('seo_pagespeed_api_key', ''); + $url = $request->url; + $strategy = $request->input('strategy', 'mobile'); + + if (empty($apiKey)) { + return response()->json(['error' => 'PageSpeed API anahtarı girilmemiş. SEO ayarlarından ekleyin.'], 422); + } + + try { + $endpoint = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=" . urlencode($url) . "&strategy={$strategy}&key={$apiKey}"; + $resp = Http::timeout(20)->get($endpoint); + + if (!$resp->successful()) { + return response()->json(['error' => 'PageSpeed API hatası: ' . $resp->status()], 422); + } + + $data = $resp->json(); + $categories = $data['lighthouseResult']['categories'] ?? []; + $audits = $data['lighthouseResult']['audits'] ?? []; + + $scores = [ + 'performance' => round(($categories['performance']['score'] ?? 0) * 100), + 'accessibility' => round(($categories['accessibility']['score'] ?? 0) * 100), + 'seo' => round(($categories['seo']['score'] ?? 0) * 100), + 'best_practices'=> round(($categories['best-practices']['score'] ?? 0) * 100), + ]; + + $opportunities = []; + foreach ($audits as $id => $audit) { + if (($audit['score'] ?? 1) < 0.9 && isset($audit['details']['type']) && $audit['details']['type'] === 'opportunity') { + $opportunities[] = [ + 'title' => $audit['title'], + 'description' => $audit['description'] ?? '', + 'savings' => $audit['details']['overallSavingsMs'] ?? null, + ]; + } + } + + $fcp = $audits['first-contentful-paint']['displayValue'] ?? null; + $lcp = $audits['largest-contentful-paint']['displayValue'] ?? null; + $cls = $audits['cumulative-layout-shift']['displayValue'] ?? null; + $tbt = $audits['total-blocking-time']['displayValue'] ?? null; + + return response()->json([ + 'scores' => $scores, + 'vitals' => compact('fcp', 'lcp', 'cls', 'tbt'), + 'opportunities' => array_slice($opportunities, 0, 8), + ]); + } catch (\Throwable $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + + // ── Internal Links Audit ────────────────────────────────────────────────── + + public function internalLinksAudit() + { + // Find animes with no other anime referencing them in descriptions (orphaned) + $allAnimes = Anime::where('is_published', true)->get(['id', 'title', 'slug']); + $result = []; + + foreach ($allAnimes as $anime) { + $mentionedIn = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->where('description', 'like', '%' . $anime->title . '%') + ->count(); + $result[] = [ + 'id' => $anime->id, + 'title' => $anime->title, + 'slug' => $anime->slug, + 'mentioned_in'=> $mentionedIn, + ]; + } + + usort($result, fn($a, $b) => $a['mentioned_in'] <=> $b['mentioned_in']); + + return response()->json(array_slice($result, 0, 50)); + } + + // ── Duplicate Content ───────────────────────────────────────────────────── + + public function duplicateContent() + { + $dups = DB::table('animes') + ->select('description', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(title ORDER BY title SEPARATOR ", ") as titles')) + ->where('is_published', true) + ->whereNotNull('description') + ->where('description', '!=', '') + ->groupBy('description') + ->having('cnt', '>', 1) + ->get(); + + return response()->json($dups); + } + + // ── AI SEO Methods ──────────────────────────────────────────────────────── + + public function aiChat(Request $request) + { + $request->validate(['messages' => 'required|array', 'messages.*.role' => 'required|in:user,assistant', 'messages.*.content' => 'required|string|max:4000']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar sayfasından ekleyin.'], 422); + } + + $total = Anime::where('is_published', true)->count(); + $covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(); + $audit = $this->runAudit(); + $sitemap = $total + Genre::where('is_active', true)->count() + 2; + + $context = [ + 'anime_count' => $total, + 'seo_covered' => $covered, + 'seo_score' => $audit['score'], + 'sitemap_urls' => $sitemap, + ]; + + $reply = $ai->seoChat($request->messages, $context); + + if (!$reply) { + return response()->json(['error' => 'DeepSeek yanıt vermedi. API anahtarını kontrol edin.'], 500); + } + + return response()->json(['reply' => $reply]); + } + + public function aiGenerateAnimeSeo(Anime $anime) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $anime->loadMissing('genres'); + $result = $ai->generateAnimeSeoMeta($anime); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + $anime->update([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + + return response()->json($result); + } + + public function aiKeywordSuggest(Request $request) + { + $request->validate(['topic' => 'required|string|max:200']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $result = $ai->suggestKeywords($request->topic); + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json($result); + } + + public function aiPageAnalysis(Request $request) + { + $request->validate(['url' => 'required|url', 'title' => 'nullable|string', 'description' => 'nullable|string', 'content' => 'nullable|string']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $result = $ai->analyzePageSeo( + $request->url, + $request->input('title', ''), + $request->input('description', ''), + $request->input('content', '') + ); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json($result); + } + + public function aiFaqSchema(Request $request) + { + $request->validate(['anime_id' => 'required|integer|exists:animes,id']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $anime = Anime::with('genres')->findOrFail($request->anime_id); + $result = $ai->generateFaqSchema($anime); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json($result); + } + + public function aiContentStrategy(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $total = Anime::where('is_published', true)->count(); + $covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(); + $audit = $this->runAudit(); + $kwds = SeoKeyword::orderBy('search_volume', 'desc')->take(10)->pluck('keyword')->toArray(); + + $strategy = $ai->generateContentStrategy([ + 'seo_score' => $audit['score'], + 'anime_count' => $total, + 'seo_covered' => $covered, + ], $kwds); + + if (!$strategy) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json(['strategy' => $strategy]); + } + + public function aiRobotsTxt(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $domain = Setting::get('seo_canonical_domain', 'animexe.com'); + $result = $ai->generateRobotsTxt($domain); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json(['robots_txt' => $result]); + } + + // ── Toplu Doldurma (Batch) ──────────────────────────────────────────────── + + /** + * Yayınlanan animelerin coverage istatistiklerini döndür. + * GET /admin/seo/bulk-fill-stats + */ + public function bulkFillStats() + { + $total = Anime::where('is_published', true)->count(); + $hasDesc = Anime::where('is_published', true)->whereNotNull('description')->where('description', '!=', '')->count(); + $hasSeoTitle = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(); + $hasSeoDesc = Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(); + $hasYear = Anime::where('is_published', true)->whereNotNull('release_year')->count(); + $hasGenres = Anime::where('is_published', true)->has('genres')->count(); + + // Kaç adet işlenecek (her mode için) + $needsSeo = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count(); + $needsMeta = Anime::where('is_published', true)->where(fn($q) => + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + )->count(); + $needsAll = Anime::where('is_published', true)->where(fn($q) => + $q->whereNull('seo_title')->orWhere('seo_title', '') + ->orWhereNull('description')->orWhere('description', '') + )->count(); + + return response()->json([ + 'total' => $total, + 'has_desc' => $hasDesc, + 'has_seo_title'=> $hasSeoTitle, + 'has_seo_desc' => $hasSeoDesc, + 'has_year' => $hasYear, + 'has_genres' => $hasGenres, + 'needs_seo' => $needsSeo, + 'needs_meta' => $needsMeta, + 'needs_all' => $needsAll, + 'pct_seo' => $total > 0 ? round($hasSeoTitle / $total * 100) : 0, + 'pct_desc' => $total > 0 ? round($hasDesc / $total * 100) : 0, + ]); + } + + /** + * Toplu doldurma — batch tabanlı, timeout olmaz. + * + * POST /admin/seo/bulk-fill-batch + * body: { + * mode: 'template_seo' | 'ai_seo' | 'ai_meta' | 'ai_all', + * last_id: 0, // son işlenen anime id'si (pagination için) + * batch_size: 5, // kaç anime işlensin + * force: false, // dolu alanları da üzerine yaz + * } + * returns: { done, errors, last_id, remaining, total } + */ + public function bulkFillBatch(Request $request) + { + $mode = $request->input('mode', 'template_seo'); + $lastId = (int) $request->input('last_id', 0); + $batchSize = min((int) $request->input('batch_size', 10), 50); + $force = $request->boolean('force', false); + + $isAi = str_starts_with($mode, 'ai_'); + + if ($isAi) { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek ekleyin.'], 422); + } + } + + // Hangi animelere ihtiyaç var? + $query = Anime::where('is_published', true)->where('id', '>', $lastId); + + if (!$force) { + if ($mode === 'template_seo' || $mode === 'ai_seo') { + $query->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')); + } elseif ($mode === 'ai_meta') { + $query->where(fn($q) => + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ); + } elseif ($mode === 'ai_all') { + $query->where(fn($q) => + $q->whereNull('seo_title')->orWhere('seo_title', '') + ->orWhereNull('description')->orWhere('description', '') + ); + } + } + + $total = $query->clone()->count(); + $animes = $query->with('genres:id,name')->orderBy('id')->limit($batchSize)->get(); + + $done = 0; + $errors = 0; + $newLastId = $lastId; + + foreach ($animes as $anime) { + $newLastId = $anime->id; + + try { + if ($mode === 'template_seo') { + // Hızlı template — AI çağrısı yok + $title = trim($anime->title); + $seoTitle = mb_substr( + $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime') . ' İzle | Animexe', + 0, 70 + ); + $desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : ''; + $seoDesc = mb_substr( + $desc + ? $desc . ' Animexe\'de Türkçe altyazılı izle.' + : $title . '\'yi Türkçe altyazılı veya dublajlı ücretsiz HD olarak Animexe\'de izleyin.', + 0, 160 + ); + $kwds = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı, ' . strtolower($title) . ' türkçe dublaj'; + + $updates = ['seo_title' => $seoTitle, 'seo_meta_desc' => $seoDesc, 'seo_keywords' => $kwds]; + if ($force) { + $anime->update($updates); + } else { + $anime->update(array_filter($updates, fn($v) => !empty($v))); + } + $done++; + + } elseif ($mode === 'ai_seo') { + $result = $ai->generateAnimeSeoMeta($anime); + if ($result) { + $updates = array_filter([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + if ($force || empty($anime->seo_title)) { + $anime->update($updates); + } + $done++; + } else { + $errors++; + } + + } elseif ($mode === 'ai_meta') { + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + if ($meta) { + $this->applyAnimeMeta($anime, $meta, $force); + $done++; + } else { + $errors++; + } + + } elseif ($mode === 'ai_all') { + // Meta + SEO birlikte — 2 AI çağrısı + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + if ($meta) { + $this->applyAnimeMeta($anime->fresh(), $meta, $force); + } + + $anime->loadMissing('genres'); + $seoResult = $ai->generateAnimeSeoMeta($anime->fresh(['genres'])); + if ($seoResult) { + $anime->update(array_filter([ + 'seo_title' => $seoResult['seo_title'] ?? null, + 'seo_meta_desc' => $seoResult['seo_meta_desc'] ?? null, + 'seo_keywords' => $seoResult['seo_keywords'] ?? null, + ])); + $done++; + } else { + $errors++; + } + } + + } catch (\Throwable $e) { + $errors++; + \Illuminate\Support\Facades\Log::warning("[bulkFillBatch] Hata [{$anime->id}] {$anime->title}: " . $e->getMessage()); + } + + // AI çağrıları arası kısa bekleme (rate limit önlemi) + if ($isAi && $done + $errors < count($animes)) { + usleep(800_000); // 0.8s + } + } + + // Kalan animeler (bu batch'ten sonra) + $remaining = max(0, $total - $done - $errors); + + return response()->json([ + 'done' => $done, + 'errors' => $errors, + 'last_id' => $newLastId, + 'remaining' => $remaining, + 'total' => $total, + 'finished' => $animes->count() < $batchSize || $remaining === 0, + ]); + } + + private function applyAnimeMeta(Anime $anime, array $meta, bool $force): void + { + $updates = []; + $fill = function (string $field, $value) use ($anime, $force, &$updates) { + if ($value === null || $value === '') return; + if ($force || empty($anime->$field)) $updates[$field] = $value; + }; + + $fill('description', $meta['description'] ?? null); + $fill('release_year', $meta['release_year'] ?? null); + $fill('studio', $meta['studio'] ?? null); + $fill('type', $meta['type'] ?? null); + $fill('status', $meta['status'] ?? null); + $fill('title_en', $meta['title_en'] ?? null); + $fill('title_jp', $meta['title_jp'] ?? null); + if (!empty($meta['rating']) && ($force || !$anime->rating)) { + $updates['rating'] = min(10, max(0, (float) $meta['rating'])); + } + + if (!empty($updates)) $anime->update($updates); + + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $ids = []; + foreach ($meta['genres'] as $name) { + $g = \App\Models\Genre::firstOrCreate( + ['name' => $name], + ['slug' => \Illuminate\Support\Str::slug($name)] + ); + $ids[] = $g->id; + } + if ($ids) { + $force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids); + } + } + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private function runAudit(): array + { + $total = Anime::where('is_published', true)->count(); + $noDesc = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count(); + $noCover = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('cover_image')->orWhere('cover_image', ''))->count(); + $shortDesc = Anime::where('is_published', true)->whereNotNull('description')->whereRaw('CHAR_LENGTH(description) < 100')->count(); + $noSlug = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('slug')->orWhere('slug', ''))->count(); + $noSeoTitle = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count(); + + $seo = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key'); + $robots = File::exists(public_path('robots.txt')) ? File::get(public_path('robots.txt')) : ''; + $hasSitemap = file_exists(public_path('sitemap.xml')); + + $dupDesc = DB::table('animes')->select('description')->where('is_published', true) + ->whereNotNull('description')->where('description', '!=', '') + ->groupBy('description')->havingRaw('COUNT(*) > 1')->count(); + + $checks = []; + + // Site config + $checks[] = $this->check('site_name', !empty($seo['seo_site_name']), 'Site Adı Ayarlandı', 'Site adı eksik', 10); + $checks[] = $this->check('home_title', !empty($seo['seo_home_title']), 'Anasayfa Başlığı Mevcut', 'Anasayfa başlığı eksik', 10); + $checks[] = $this->check('home_desc', !empty($seo['seo_home_description']), 'Anasayfa Meta Açıklaması Mevcut', 'Anasayfa meta açıklaması eksik', 10); + $checks[] = $this->check('title_length', strlen($seo['seo_home_title'] ?? '') <= 70 && strlen($seo['seo_home_title'] ?? '') >= 30, 'Başlık Uzunluğu İdeal (30–70)', 'Başlık çok kısa veya çok uzun', 5); + $checks[] = $this->check('desc_length', strlen($seo['seo_home_description'] ?? '') <= 160 && strlen($seo['seo_home_description'] ?? '') >= 100, 'Meta Açıklama Uzunluğu İdeal', 'Meta açıklama 100–160 karakter arası olmalı', 5); + $checks[] = $this->check('og_image', !empty($seo['seo_og_image']), 'OG Görseli Tanımlandı', 'Varsayılan OG görseli eksik', 8); + $checks[] = $this->check('canonical', !empty($seo['seo_canonical_domain']), 'Canonical Domain Ayarlı', 'Canonical domain ayarlanmamış', 8); + $checks[] = $this->check('analytics', !empty($seo['seo_google_analytics']), 'Google Analytics Entegre', 'GA4 ID girilmemiş', 7); + $checks[] = $this->check('gsc', !empty($seo['seo_gsc_verification']), 'Search Console Doğrulandı', 'GSC doğrulama kodu eksik', 7); + $checks[] = $this->check('schema', ($seo['seo_enable_schema'] ?? '1') === '1', 'Schema.org İşaretleme Aktif', 'Schema.org işaretleme kapalı', 7); + $checks[] = $this->check('faq_schema', ($seo['seo_enable_faq_schema'] ?? '1') === '1', 'FAQ Schema Aktif', 'FAQ şema kapalı (rich snippets kayıp)', 5); + $checks[] = $this->check('video_schema', ($seo['seo_enable_video_schema'] ?? '1') === '1', 'Video Schema Aktif', 'Video şema kapalı', 5); + + // Technical SEO + $checks[] = $this->check('sitemap', $hasSitemap, 'Sitemap Mevcut', 'sitemap.xml bulunamadı', 8); + $checks[] = $this->check('robots_exists', !empty($robots), 'robots.txt Mevcut', 'robots.txt yok veya boş', 6); + $checks[] = $this->check('robots_admin', str_contains($robots, 'Disallow: /admin'), 'robots.txt Admin Kapalı', 'robots.txt /admin dizini kapalı değil', 6); + $checks[] = $this->check('noindex_search', ($seo['seo_noindex_search'] ?? '1') === '1', 'Arama Sayfası Noindex', 'Arama sayfası indexleniyor', 5); + $checks[] = $this->check('twitter', !empty($seo['seo_twitter_site']), 'Twitter Card Yapılandırıldı', 'Twitter hesabı girilmemiş', 4); + $checks[] = $this->check('bing', !empty($seo['seo_bing_verification']), 'Bing Webmaster Doğrulandı', 'Bing doğrulama kodu eksik', 3); + + // Content quality + $checks[] = $this->check('anime_desc', $noDesc === 0, 'Tüm Animelerin Açıklaması Var', "{$noDesc} animenin açıklaması eksik", 8); + $checks[] = $this->check('anime_cover', $noCover === 0, 'Tüm Animelerin Kapağı Var', "{$noCover} animenin görseli eksik", 7); + $checks[] = $this->check('desc_quality', $shortDesc < max(1, $total * 0.1), 'Açıklama Kalitesi İyi', "{$shortDesc} animenin açıklaması çok kısa", 4); + $checks[] = $this->check('slug_coverage', $noSlug === 0, 'Tüm Animeler URL Slug\'a Sahip', "{$noSlug} animenin slug\'u eksik", 6); + $checks[] = $this->check('seo_titles', $noSeoTitle < $total * 0.2, 'Anime SEO Başlıkları Yeterli', "{$noSeoTitle} animenin SEO başlığı eksik", 6); + $checks[] = $this->check('dup_desc', $dupDesc === 0, 'Tekrarlayan İçerik Yok', "{$dupDesc} grup tekrarlayan açıklama var", 5); + + $score = $weight = 0; + foreach ($checks as $c) { + $weight += $c['weight']; + if ($c['pass']) $score += $c['weight']; + } + + $scorePercent = $weight > 0 ? round(($score / $weight) * 100) : 0; + + return [ + 'score' => $scorePercent, + 'checks' => $checks, + 'totals' => ['total' => $total, 'noDesc' => $noDesc, 'noCover' => $noCover, 'shortDesc' => $shortDesc, 'noSeoTitle' => $noSeoTitle, 'dupDesc' => $dupDesc], + 'pass_count' => collect($checks)->where('pass', true)->count(), + 'fail_count' => collect($checks)->where('pass', false)->count(), + ]; + } + + private function check(string $id, bool $pass, string $passMsg, string $failMsg, int $weight): array + { + return compact('id', 'pass', 'passMsg', 'failMsg', 'weight'); + } +} diff --git a/app/Http/Controllers/Admin/SettingController.php b/app/Http/Controllers/Admin/SettingController.php new file mode 100644 index 0000000..5e7a742 --- /dev/null +++ b/app/Http/Controllers/Admin/SettingController.php @@ -0,0 +1,160 @@ +keyBy('key'); + return view('admin.settings.index', compact('settings')); + } + + public function update(Request $request) + { + $data = $request->except(['_token', '_method', 'intro_video_file']); + + // Checkbox keys: explicitly set to '0' when not present in request + $booleanKeys = [ + 'comments_enabled', 'comments_require_approval', + 'intro_enabled', 'nav_show_messages', + 'ai_auto_description', 'ai_auto_seo', + 'premium_free_mode', + 'ads_enabled', + ]; + foreach ($booleanKeys as $k) { + if (!array_key_exists($k, $data)) { + $data[$k] = '0'; + } + } + + foreach ($data as $key => $value) { + Setting::set($key, $value); + } + + cache()->forget('premium_free_mode'); + + return back()->with('success', 'Ayarlar kaydedildi.'); + } + + /** + * Favicon yükle — public/favicon.{ext} olarak kaydet, setting'e yaz. + */ + public function uploadFavicon(Request $request) + { + $request->validate(['favicon_file' => 'required|file|mimes:png,ico,svg,jpg,jpeg|max:2048']); + + $file = $request->file('favicon_file'); + $ext = strtolower($file->getClientOriginalExtension()) ?: 'png'; + $dest = public_path('favicon.' . $ext); + + // Eski favicon dosyalarını temizle + foreach (['png', 'ico', 'svg', 'jpg', 'jpeg'] as $e) { + $old = public_path('favicon.' . $e); + if (file_exists($old) && $old !== $dest) @unlink($old); + } + + $file->move(public_path(), 'favicon.' . $ext); + + $url = '/favicon.' . $ext; + Setting::set('site_favicon', $url); + + return back()->with('favicon_success', 'Favicon güncellendi.'); + } + + /** + * Intro videoyu BunnyCDN Storage'a yükle, URL'yi ayarlara kaydet. + */ + public function uploadIntro(Request $request) + { + $request->validate(['intro_video_file' => 'required|file|mimes:mp4,webm|max:204800']); // max 200MB + + $zone = Setting::get('bunnycdn_zone'); + $apiKey = Setting::get('bunnycdn_api_key'); + $pullUrl = rtrim(Setting::get('bunnycdn_pull_url', ''), '/'); + + if (!$zone || !$apiKey || !$pullUrl) { + return back()->with('intro_error', 'Önce BunnyCDN ayarlarını kaydedin (Zone, API Key, Pull URL).'); + } + + $file = $request->file('intro_video_file'); + $ext = $file->getClientOriginalExtension() ?: 'mp4'; + $fileName = 'intro/site-intro.' . $ext; + $apiUrl = "https://storage.bunnycdn.com/{$zone}/{$fileName}"; + + $response = Http::withHeaders([ + 'AccessKey' => $apiKey, + 'Content-Type' => $file->getMimeType(), + ])->withBody(file_get_contents($file->getRealPath()), $file->getMimeType()) + ->put($apiUrl); + + if (!$response->successful()) { + return back()->with('intro_error', 'BunnyCDN yükleme başarısız: ' . $response->status() . ' — ' . $response->body()); + } + + $cdnUrl = $pullUrl . '/' . $fileName; + Setting::set('intro_video_url', $cdnUrl, 'intro'); + + return back()->with('intro_success', 'Intro video yüklendi ve URL kaydedildi.'); + } + + public function testMail(Request $request) + { + $request->validate(['test_mail_to' => 'required|email'], [ + 'test_mail_to.required' => 'Alıcı e-posta adresi zorunludur.', + 'test_mail_to.email' => 'Geçerli bir e-posta adresi girin.', + ]); + + // DB'deki ayarları runtime'da uygula + $keys = ['mail_host','mail_port','mail_username','mail_password', + 'mail_from_address','mail_from_name','mail_encryption']; + $rows = Setting::whereIn('key', $keys)->pluck('value', 'key'); + + if (!$rows->get('mail_host')) { + return back()->with('mail_error', 'Önce SMTP ayarlarını kaydedin.'); + } + + $encryption = strtolower($rows->get('mail_encryption', 'tls')); + $port = (int) $rows->get('mail_port', 587); + + Config::set('mail.mailers.smtp.host', $rows->get('mail_host')); + Config::set('mail.mailers.smtp.port', $port); + Config::set('mail.mailers.smtp.username', $rows->get('mail_username')); + Config::set('mail.mailers.smtp.password', $rows->get('mail_password')); + Config::set('mail.mailers.smtp.encryption', $encryption); + Config::set('mail.mailers.smtp.timeout', 15); + Config::set('mail.mailers.smtp.stream', [ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + Config::set('mail.from.address', $rows->get('mail_from_address')); + Config::set('mail.from.name', $rows->get('mail_from_name', config('app.name'))); + Config::set('mail.default', 'smtp'); + Mail::purge('smtp'); + + // Socket timeout — PHP default 60s, düşür + $prevTimeout = ini_get('default_socket_timeout'); + ini_set('default_socket_timeout', '15'); + set_time_limit(30); + + try { + Mail::to($request->test_mail_to)->send(new TestMail()); + ini_set('default_socket_timeout', $prevTimeout); + return back()->with('mail_success', 'Test e-postası başarıyla gönderildi → ' . $request->test_mail_to); + } catch (\Throwable $e) { + ini_set('default_socket_timeout', $prevTimeout); + return back()->with('mail_error', 'Gönderi başarısız: ' . $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/Admin/SubscriptionController.php b/app/Http/Controllers/Admin/SubscriptionController.php new file mode 100644 index 0000000..3827b1b --- /dev/null +++ b/app/Http/Controllers/Admin/SubscriptionController.php @@ -0,0 +1,71 @@ +latest(); + + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->search) { + $query->whereHas('user', fn($q) => + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%') + ); + } + + $subscriptions = $query->paginate(30)->withQueryString(); + return view('admin.subscriptions.index', compact('subscriptions')); + } + + public function show(Subscription $subscription) + { + $subscription->load(['user', 'plan']); + return view('admin.subscriptions.show', compact('subscription')); + } + + public function store(Request $request) + { + // Manuel abonelik ekleme (UserController.givePremium ile aynı mantık) + $request->validate([ + 'user_id' => 'required|exists:users,id', + 'plan_id' => 'required|exists:membership_plans,id', + ]); + + $plan = MembershipPlan::findOrFail($request->plan_id); + $user = User::findOrFail($request->user_id); + + $hasEverSubscribed = Subscription::where('user_id', $user->id)->exists(); + $bonusDays = (!$hasEverSubscribed && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0; + $expiresAt = now()->addDays($plan->duration_days + $bonusDays); + + $user->update(['membership' => 'premium', 'premium_expires_at' => $expiresAt]); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $expiresAt, + 'payment_method' => 'manual', + ]); + + return back()->with('success', 'Abonelik eklendi.'); + } + + public function destroy(Subscription $subscription) + { + $subscription->update(['status' => 'cancelled']); + return back()->with('success', 'Abonelik iptal edildi.'); + } +} diff --git a/app/Http/Controllers/Admin/TrendingController.php b/app/Http/Controllers/Admin/TrendingController.php new file mode 100644 index 0000000..fd8e038 --- /dev/null +++ b/app/Http/Controllers/Admin/TrendingController.php @@ -0,0 +1,240 @@ +where('is_published', true) + ->orderBy('trending_order') + ->get(); + + $autoTrending = $this->getAutoTrending(20); + + // Son skor hesaplama zamanı + $lastComputed = cache()->get('trending_score_computed_at'); + + return view('admin.trending.index', compact('manual', 'autoTrending', 'lastComputed')); + } + + // ── Manuel trending yönetimi ────────────────────────────────────────────── + + public function toggle(Request $request, Anime $anime) + { + $newState = !$anime->is_trending; + + if ($newState) { + $maxOrder = Anime::where('is_trending', true)->max('trending_order') ?? 0; + $anime->update([ + 'is_trending' => true, + 'trending_order' => $maxOrder + 1, + 'trending_score' => $anime->trending_score + 200, // Manuel boost + ]); + } else { + $anime->update(['is_trending' => false, 'trending_order' => 0]); + $this->reorderAll(); + } + + if ($request->wantsJson()) { + return response()->json(['ok' => true, 'is_trending' => $newState]); + } + return back()->with('success', $newState + ? '"'.$anime->title.'" trend listesine eklendi.' + : '"'.$anime->title.'" trend listesinden çıkarıldı.'); + } + + public function reorder(Request $request) + { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + foreach ($request->ids as $i => $id) { + Anime::where('id', $id)->update(['trending_order' => $i + 1]); + } + return response()->json(['ok' => true]); + } + + public function move(Request $request, Anime $anime) + { + $direction = $request->input('direction'); + $current = $anime->trending_order; + + if ($direction === 'up' && $current > 1) { + $swap = Anime::where('is_trending', true)->where('trending_order', $current - 1)->first(); + if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current - 1]); } + } elseif ($direction === 'down') { + $swap = Anime::where('is_trending', true)->where('trending_order', $current + 1)->first(); + if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current + 1]); } + } + + return back(); + } + + public function search(Request $request) + { + $results = Anime::where('is_published', true) + ->where('title', 'like', "%{$request->query('q', '')}%") + ->select('id', 'title', 'cover_image', 'is_trending', 'release_year', 'trending_score') + ->take(8)->get() + ->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'cover' => $a->coverUrl, + 'is_trending' => (bool) $a->is_trending, + 'year' => $a->release_year, + 'trending_score'=> round($a->trending_score, 1), + ]); + return response()->json(['results' => $results]); + } + + // ── Trend Skoru Hesaplama (YouTube algoritması) ─────────────────────────── + + /** + * Admin butonu: tüm animelerin trend skorunu hesapla ve kaydet. + * POST /admin/trending/compute-scores + */ + public function computeScores() + { + $count = self::runScoreComputation(); + cache()->put('trending_score_computed_at', now()->toDateTimeString(), 3600); + cache()->flush(); // Anasayfa cache'ini temizle + + return response()->json([ + 'ok' => true, + 'updated' => $count, + 'message' => "{$count} anime için trend skoru güncellendi.", + ]); + } + + /** + * YouTube-benzeri Trend Skoru Algoritması + * ───────────────────────────────────────── + * score = view_24h × 12 ← Son 24 saatin izlenme sayısı (en yüksek ağırlık) + * + view_7d × 4 ← Son 7 günün izlenme sayısı + * + view_30d × 1 ← Son 30 günün izlenme sayısı + * + watch_minutes_7d × 0.8 ← Gerçek izleme dakikası (kalite sinyali) + * + new_episode_bonus ← Yeni bölüm varsa büyük bonus + * + rating × 4 ← Kalite sinyali + * + manual_boost ← Manuel trending = +250 + * + * Decay: Eski içeriklerin skoru doğal olarak düşer (view_count azalır). + * Herhangi bir yeni bölüm veya izlenme olmadan skor sıfıra yaklaşır. + */ + public static function runScoreComputation(): int + { + $now = now(); + $day1 = $now->copy()->subDay(); + $day7 = $now->copy()->subDays(7); + $day30 = $now->copy()->subDays(30); + + $animes = DB::table('animes') + ->where('is_published', true) + ->select('id', 'rating', 'is_trending', 'status') + ->get(); + + $updated = 0; + + foreach ($animes as $anime) { + // ── Bölüm izlenme sayıları (view_count zaman dilimine göre) ────── + // Episode.updated_at → son izleme zamanının proxy'si + $views = DB::table('episodes') + ->where('anime_id', $anime->id) + ->where('is_published', true) + ->selectRaw(" + SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v24h, + SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v7d, + SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v30d + ", [$day1, $day7, $day30]) + ->first(); + + // ── Gerçek izleme dakikası (analytics_watch_events) ───────────── + $watchMinutes = 0; + try { + $watchMinutes = DB::table('analytics_watch_events') + ->where('anime_id', $anime->id) + ->where('created_at', '>=', $day7) + ->sum('seconds_watched') / 60; + } catch (\Throwable) {} + + // ── Yeni bölüm bonusu ──────────────────────────────────────────── + $newEpBonus = 0; + $latestEpDate = DB::table('episodes') + ->where('anime_id', $anime->id) + ->where('is_published', true) + ->max('created_at'); + + if ($latestEpDate) { + $epAge = now()->diffInHours($latestEpDate); + if ($epAge <= 24) $newEpBonus = 80; // Bugün yeni bölüm → çok büyük boost + elseif ($epAge <= 72) $newEpBonus = 40; // Son 3 gün + elseif ($epAge <= 168) $newEpBonus = 15; // Son 7 gün + elseif ($epAge <= 720) $newEpBonus = 5; // Son 30 gün + } + + // ── Ongoing bonus ───────────────────────────────────────────────── + $ongoingBonus = ($anime->status === 'ongoing') ? 10 : 0; + + // ── Manuel trending boost ───────────────────────────────────────── + $manualBoost = $anime->is_trending ? 250 : 0; + + // ── Skor hesapla ───────────────────────────────────────────────── + $score = + ($views->v24h ?? 0) * 12 + + ($views->v7d ?? 0) * 4 + + ($views->v30d ?? 0) * 1 + + $watchMinutes * 0.8 + + $newEpBonus + + $ongoingBonus + + ((float)($anime->rating ?? 5)) * 4 + + $manualBoost; + + DB::table('animes') + ->where('id', $anime->id) + ->update(['trending_score' => round($score, 2)]); + + $updated++; + } + + return $updated; + } + + /** + * Auto-trending: trending_score'a göre sırala. + * Fallback: score kolonu yoksa eski yönteme dön. + */ + public static function getAutoTrending(int $limit = 12): \Illuminate\Support\Collection + { + try { + return Anime::where('is_published', true) + ->orderByDesc('trending_score') + ->take($limit) + ->get(); + } catch (\Throwable) { + // trending_score kolonu henüz oluşturulmamış → eski yöntem + return Anime::where('is_published', true) + ->withSum(['episodes as recent_views' => fn($q) => + $q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30)) + ], 'view_count') + ->orderByDesc('recent_views') + ->take($limit) + ->get(); + } + } + + private function reorderAll(): void + { + $animes = Anime::where('is_trending', true)->orderBy('trending_order')->get(); + foreach ($animes as $i => $a) { + $a->update(['trending_order' => $i + 1]); + } + } +} diff --git a/app/Http/Controllers/Admin/UserAnalyticsController.php b/app/Http/Controllers/Admin/UserAnalyticsController.php new file mode 100644 index 0000000..f483cc3 --- /dev/null +++ b/app/Http/Controllers/Admin/UserAnalyticsController.php @@ -0,0 +1,140 @@ +input('tab', 'overview'); // overview | bots | activity | country + $country = $request->input('country'); + $period = (int) $request->input('period', 30); // days + $from = now()->subDays($period); + + // ── Overview stats ──────────────────────────────────────────────────── + $totalReal = User::where('role', '!=', 'admin')->count(); + $newReal = User::where('role', '!=', 'admin')->where('created_at', '>=', $from)->count(); + $active30 = DB::table('analytics_pageviews') + ->where('is_bot', 0)->where('created_at', '>=', $from) + ->distinct('user_id')->whereNotNull('user_id')->count('user_id'); + $botViews = DB::table('analytics_pageviews') + ->where('is_bot', 1)->where('created_at', '>=', $from)->count(); + $realViews = DB::table('analytics_pageviews') + ->where('is_bot', 0)->where('created_at', '>=', $from)->count(); + + // ── Daily new users (chart) ─────────────────────────────────────────── + $dailyNew = DB::table('users') + ->selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->where('role', '!=', 'admin') + ->where('created_at', '>=', $from) + ->groupBy('day')->orderBy('day') + ->pluck('cnt', 'day'); + + // ── Country breakdown ───────────────────────────────────────────────── + $countriesQuery = DB::table('analytics_pageviews') + ->selectRaw('country, COUNT(*) as views, COUNT(DISTINCT user_id) as users') + ->where('is_bot', 0) + ->where('created_at', '>=', $from) + ->whereNotNull('country') + ->groupBy('country') + ->orderByDesc('views'); + if ($country) $countriesQuery->where('country', $country); + $countries = $countriesQuery->limit(50)->get(); + + // ── Bot analysis ────────────────────────────────────────────────────── + $botStats = DB::table('analytics_bot_logs') + ->selectRaw('bot_name, action, COUNT(*) as cnt') + ->where('created_at', '>=', $from) + ->groupBy('bot_name', 'action') + ->orderByDesc('cnt') + ->limit(30)->get(); + + $topBotIps = DB::table('analytics_bot_logs') + ->selectRaw('ip, COUNT(*) as cnt') + ->where('created_at', '>=', $from) + ->groupBy('ip') + ->orderByDesc('cnt') + ->limit(20)->get(); + + $blockedIps = DB::table('blocked_ips') + ->orderByDesc('blocked_at') + ->limit(30)->get(); + + // ── User activity log ───────────────────────────────────────────────── + $actQuery = UserActivityLog::with('user:id,name,username,avatar') + ->where('created_at', '>=', $from); + if ($country) $actQuery->where('country', $country); + if ($request->input('user_id')) $actQuery->where('user_id', $request->input('user_id')); + if ($request->input('action')) $actQuery->where('action', $request->input('action')); + $actQuery->orderByDesc('created_at'); + $actLogs = $actQuery->paginate(50)->withQueryString(); + + // ── Top active users ────────────────────────────────────────────────── + $topUsers = DB::table('user_activity_logs') + ->selectRaw('user_id, COUNT(*) as actions') + ->where('is_bot', 0)->whereNotNull('user_id') + ->where('created_at', '>=', $from) + ->groupBy('user_id')->orderByDesc('actions') + ->limit(10)->get(); + $topUserIds = $topUsers->pluck('user_id'); + $topUserMap = User::whereIn('id', $topUserIds)->get()->keyBy('id'); + + // ── Action breakdown ────────────────────────────────────────────────── + $actionBreakdown = DB::table('user_activity_logs') + ->selectRaw('action, COUNT(*) as cnt') + ->where('is_bot', 0) + ->where('created_at', '>=', $from) + ->groupBy('action')->orderByDesc('cnt') + ->get(); + + // ── Device breakdown ────────────────────────────────────────────────── + $deviceBreakdown = DB::table('analytics_pageviews') + ->selectRaw('device, COUNT(*) as cnt') + ->where('is_bot', 0)->where('created_at', '>=', $from) + ->groupBy('device')->orderByDesc('cnt')->get(); + + return view('admin.analytics.users', compact( + 'tab', 'period', 'country', + 'totalReal', 'newReal', 'active30', 'botViews', 'realViews', + 'dailyNew', 'countries', 'botStats', 'topBotIps', 'blockedIps', + 'actLogs', 'topUsers', 'topUserMap', 'actionBreakdown', 'deviceBreakdown' + )); + } + + public function userDetail(Request $request, User $user) + { + $period = (int) $request->input('period', 30); + $from = now()->subDays($period); + + $logs = UserActivityLog::where('user_id', $user->id) + ->where('created_at', '>=', $from) + ->orderByDesc('created_at') + ->paginate(50)->withQueryString(); + + $actBreakdown = DB::table('user_activity_logs') + ->selectRaw('action, COUNT(*) as cnt') + ->where('user_id', $user->id)->where('created_at', '>=', $from) + ->groupBy('action')->orderByDesc('cnt')->get(); + + $pageviews = DB::table('analytics_pageviews') + ->where('user_id', $user->id)->where('created_at', '>=', $from) + ->orderByDesc('created_at')->limit(100)->get(); + + $watchEvents = DB::table('analytics_watch_events as we') + ->join('episodes as e', 'e.id', '=', 'we.episode_id') + ->join('animes as a', 'a.id', '=', 'we.anime_id') + ->selectRaw('we.created_at, a.title as anime_title, e.episode_number, we.percent_complete, we.seconds_watched') + ->where('we.user_id', $user->id)->where('we.created_at', '>=', $from) + ->orderByDesc('we.created_at')->limit(50)->get(); + + return view('admin.analytics.user-detail', compact( + 'user', 'logs', 'actBreakdown', 'pageviews', 'watchEvents', 'period' + )); + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..b66d0dd --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,153 @@ +search) { + $query->where(function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%'); + }); + } + if ($request->membership) { + $query->where('membership', $request->membership); + } + if ($request->role) { + $query->where('role', $request->role); + } + if ($request->banned) { + $query->where('is_banned', true); + } + + $users = $query->paginate(30)->withQueryString(); + return view('admin.users.index', compact('users')); + } + + public function show(User $user) + { + $user->load(['subscriptions.plan', 'comments']); + $plans = MembershipPlan::where('is_active', true)->get(); + return view('admin.users.show', compact('user', 'plans')); + } + + public function edit(User $user) + { + return view('admin.users.edit', compact('user')); + } + + public function update(Request $request, User $user) + { + if ($user->isAdmin() && !auth()->user()->isAdmin()) { + return back()->with('error', 'Admin kullanıcı düzenlenemez.'); + } + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email,' . $user->id, + 'role' => 'required|in:user,moderator,admin', + 'password' => 'nullable|string|min:8', + 'admin_badge' => 'nullable|string|max:32', + ]); + + if (!empty($data['password'])) { + $data['password'] = Hash::make($data['password']); + } else { + unset($data['password']); + } + + $user->update($data); + return redirect()->route('admin.users.show', $user)->with('success', 'Kullanıcı güncellendi.'); + } + + public function destroy(User $user) + { + if ($user->id === auth()->id()) { + return back()->with('error', 'Kendinizi silemezsiniz.'); + } + if ($user->isAdmin()) { + return back()->with('error', 'Admin kullanıcı silinemez.'); + } + $user->delete(); + return redirect()->route('admin.users.index')->with('success', 'Kullanıcı silindi.'); + } + + public function ban(Request $request, User $user) + { + $request->validate(['ban_reason' => 'nullable|string|max:500']); + + if ($user->isAdmin()) { + return back()->with('error', 'Admin kullanıcı banlanamaz.'); + } + + $user->update([ + 'is_banned' => true, + 'ban_reason' => $request->ban_reason, + 'banned_at' => now(), + ]); + + return back()->with('success', $user->name . ' banlandı.'); + } + + public function unban(User $user) + { + $user->update([ + 'is_banned' => false, + 'ban_reason' => null, + 'banned_at' => null, + ]); + return back()->with('success', $user->name . ' bandan çıkarıldı.'); + } + + public function givePremium(Request $request, User $user) + { + $request->validate([ + 'plan_id' => 'required|exists:membership_plans,id', + ]); + + $plan = MembershipPlan::findOrFail($request->plan_id); + $expiresAt = now()->addDays($plan->duration_days); + + $user->update([ + 'membership' => 'premium', + 'premium_expires_at' => $expiresAt, + ]); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $expiresAt, + 'payment_method' => 'manual', + 'notes' => 'Admin tarafından verildi: ' . auth()->user()->name, + ]); + + return back()->with('success', $user->name . "'e {$plan->duration_days} günlük premium verildi."); + } + + public function removePremium(User $user) + { + $user->update([ + 'membership' => 'free', + 'premium_expires_at' => null, + ]); + + Subscription::where('user_id', $user->id) + ->where('status', 'active') + ->update(['status' => 'cancelled']); + + return back()->with('success', $user->name . "'in premiumu kaldırıldı."); + } +} diff --git a/app/Http/Controllers/Api/AdApiController.php b/app/Http/Controllers/Api/AdApiController.php new file mode 100644 index 0000000..ef9c69c --- /dev/null +++ b/app/Http/Controllers/Api/AdApiController.php @@ -0,0 +1,23 @@ +increment('impressions'); + return response()->json(['ok' => true]); + } + + // POST /api/ads/{ad}/click + public function click(Ad $ad) + { + $ad->increment('clicks'); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/AiApiController.php b/app/Http/Controllers/Api/AiApiController.php new file mode 100644 index 0000000..ee6ac9b --- /dev/null +++ b/app/Http/Controllers/Api/AiApiController.php @@ -0,0 +1,37 @@ +ai = $ai; + } + + public function chat(Request $request) + { + return $this->ai->chat($request); + } + + public function recommend(Request $request) + { + return $this->ai->recommend($request); + } + + public function similar(Request $request) + { + return $this->ai->similar($request); + } + + public function search(Request $request) + { + return $this->ai->search($request); + } +} diff --git a/app/Http/Controllers/Api/AnimeApiController.php b/app/Http/Controllers/Api/AnimeApiController.php new file mode 100644 index 0000000..614ee0b --- /dev/null +++ b/app/Http/Controllers/Api/AnimeApiController.php @@ -0,0 +1,534 @@ +where('is_published', true) + ->with('genres', 'seasons', 'episodes') + ->latest()->take(5)->get(); + + if ($featured->isEmpty()) { + $featured = Anime::where('is_published', true)->with('genres', 'seasons', 'episodes') + ->where('rating', '>=', 1)->orderByDesc('rating')->take(5)->get(); + } + + $latest = Anime::where('is_published', true)->latest()->take(20)->get(); + $topRated = Anime::where('is_published', true)->where('rating', '>=', 7) + ->orderByDesc('rating')->take(12)->get(); + + try { + $manualTrending = Anime::where('is_trending', true)->where('is_published', true) + ->orderBy('trending_order')->take(12)->get(); + + if ($manualTrending->count() >= 6) { + $trending = $manualTrending->take(12); + } else { + $autoIds = $manualTrending->pluck('id')->toArray(); + $autoFill = Anime::where('is_published', true) + ->whereNotIn('id', $autoIds) + ->withSum(['episodes as recent_views' => fn($q) => + $q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30)) + ], 'view_count') + ->orderByDesc('recent_views') + ->take(12 - $manualTrending->count())->get(); + $trending = $manualTrending->concat($autoFill); + } + } catch (\Throwable) { + $trending = collect(); + } + + if ($trending->isEmpty()) $trending = $latest->take(12); + + $newEpisodes = Episode::with(['anime', 'season']) + ->where('is_published', true)->latest()->take(12)->get() + ->filter(fn($e) => $e->anime && $e->season)->values(); + + $genres = Genre::where('is_active', true)->take(16)->get(); + + $continueWatching = collect(); + $recommended = collect(); + + $authUser = auth('sanctum')->user(); + if ($authUser) { + try { + $continueWatching = ContinueWatching::where('user_id', $authUser->id) + ->with('anime:id,title,slug,cover_image') + ->where('percent_complete', '>=', 5) + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at')->limit(10)->get(); + + $watchedIds = ContinueWatching::where('user_id', $authUser->id)->pluck('anime_id'); + if ($watchedIds->isNotEmpty()) { + $topGenreIds = DB::table('anime_genre') + ->whereIn('anime_id', $watchedIds) + ->select('genre_id', DB::raw('count(*) as cnt')) + ->groupBy('genre_id')->orderByDesc('cnt')->limit(3)->pluck('genre_id'); + + if ($topGenreIds->isNotEmpty()) { + $recommended = Anime::where('is_published', true) + ->whereNotIn('id', $watchedIds) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds)) + ->where('rating', '>=', 6)->inRandomOrder()->take(12)->get(); + } + } + } catch (\Throwable $e) {} + } + + return response()->json([ + 'featured' => $featured->map(fn($a) => $this->animeResource($a, true)), + 'trending' => $trending->values()->map(fn($a) => $this->animeResource($a)), + 'latest' => $latest->map(fn($a) => $this->animeResource($a)), + 'top_rated' => $topRated->map(fn($a) => $this->animeResource($a)), + 'new_episodes' => $newEpisodes->map(fn($e) => $this->episodeCardResource($e)), + 'genres' => $genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]), + 'continue_watching'=> $continueWatching->map(fn($cw) => $this->continueWatchingResource($cw)), + 'recommended' => $recommended->map(fn($a) => $this->animeResource($a)), + ]); + } + + // GET /api/animes + public function index(Request $request) + { + $q = $request->input('q', ''); + $genre = $request->input('genre'); + $type = $request->input('type'); + $status = $request->input('status'); + $year = $request->input('year'); + $sort = $request->input('sort', 'latest'); // latest|rating|views + + $query = Anime::where('is_published', true)->with('genres'); + + if ($q) { + $query->where(function ($qb) use ($q) { + $qb->where('title', 'like', "%$q%") + ->orWhere('title_en', 'like', "%$q%") + ->orWhere('title_jp', 'like', "%$q%"); + }); + } + if ($genre) $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre)); + if ($type) $query->where('type', $type); + if ($status) $query->where('status', $status); + if ($year) $query->where('release_year', $year); + + match ($sort) { + 'rating' => $query->orderByDesc('rating'), + default => $query->latest(), + }; + + $results = $query->paginate(24)->withQueryString(); + + return response()->json([ + 'data' => collect($results->items())->map(fn($a) => $this->animeResource($a)), + 'total' => $results->total(), + 'per_page' => $results->perPage(), + 'current_page'=> $results->currentPage(), + 'last_page' => $results->lastPage(), + ]); + } + + // GET /api/animes/{slug} + public function show(Request $request, string $slug) + { + $anime = Anime::where('slug', $slug)->where('is_published', true) + ->with(['genres', 'seasons', 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number')]) + ->firstOrFail(); + + $userId = $request->user()?->id; + + $inWatchlist = false; + $userRating = null; + $isFollowing = false; + + if ($userId) { + $wl = \App\Models\Watchlist::where('user_id', $userId)->where('anime_id', $anime->id)->first(); + $inWatchlist = $wl !== null; + $watchlistStatus = $wl?->status; + $userRating = \App\Models\AnimeRating::where('user_id', $userId)->where('anime_id', $anime->id)->value('rating'); + $isFollowing = \App\Models\AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->exists(); + } + + $seasons = $anime->seasons->map(function ($season) { + return [ + 'id' => $season->id, + 'season_number' => $season->season_number, + 'title' => $season->title, + 'episodes' => $season->episodes->map(fn($ep) => $this->episodeResource($ep)), + ]; + }); + + return response()->json([ + 'anime' => $this->animeResource($anime, true), + 'seasons' => $seasons, + 'in_watchlist' => $inWatchlist, + 'watchlist_status'=> $watchlistStatus ?? null, + 'user_rating' => $userRating, + 'is_following' => $isFollowing, + ]); + } + + // GET /api/genres/{slug} + public function genre(Request $request, string $slug) + { + $genre = Genre::where('slug', $slug)->where('is_active', true)->firstOrFail(); + $animes = $genre->animes()->where('is_published', true)->latest()->paginate(24); + + return response()->json([ + 'genre' => ['id'=>$genre->id,'name'=>$genre->name,'slug'=>$genre->slug], + 'data' => collect($animes->items())->map(fn($a) => $this->animeResource($a)), + 'total' => $animes->total(), + 'last_page' => $animes->lastPage(), + 'current_page' => $animes->currentPage(), + ]); + } + + // GET /api/genres + public function genres() + { + $genres = Genre::where('is_active', true)->orderBy('name')->get(); + return response()->json($genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])); + } + + // GET /api/watch/{slug}/{season}/{episode} + public function watch(Request $request, string $slug, int $season, int $episode) + { + $anime = Anime::where('slug', $slug)->where('is_published', true)->firstOrFail(); + $seasonModel = $anime->seasons()->where('season_number', $season)->firstOrFail(); + $ep = $seasonModel->episodes()->where('episode_number', $episode)->where('is_published', true)->firstOrFail(); + + $ep->increment('view_count'); + + $prev = $seasonModel->episodes()->where('episode_number', '<', $episode)->where('is_published', true)->orderByDesc('episode_number')->first(); + $next = $seasonModel->episodes()->where('episode_number', '>', $episode)->where('is_published', true)->orderBy('episode_number')->first(); + + // Cross-season next + if (!$next) { + $nextSeason = $anime->seasons()->where('season_number', $season + 1)->first(); + if ($nextSeason) { + $next = $nextSeason->episodes()->where('episode_number', 1)->where('is_published', true)->first(); + } + } + + // Subtitles + $subtitles = []; + if (method_exists($ep, 'subtitles')) { + $subtitles = $ep->subtitles()->get()->map(fn($s) => [ + 'label' => $s->label, + 'lang' => $s->language, + 'url' => $s->url, + 'is_default' => (bool)($s->is_default ?? false), + ])->values()->toArray(); + } + + // Dub sources from m3u8 URL + $dubSources = []; + if ($ep->m3u8_url) { + $rawDubs = $ep->available_dubs ?? null; + $availableDubs = is_string($rawDubs) ? json_decode($rawDubs, true) : (is_array($rawDubs) ? $rawDubs : null); + [$dubSources] = \App\Http\Controllers\Frontend\PlayerController::resolveDubSourcesPublic( + $ep->m3u8_url, $availableDubs + ); + } + + // Quality sources (legacy — episode.video_url / m3u8_url) + $sources = collect(); + if ($ep->m3u8_url) $sources->push(['quality'=>'Auto (HLS)','url'=>$ep->m3u8_url,'type'=>'hls']); + if ($ep->video_url) { + $isHls = str_ends_with($ep->video_url, '.m3u8') || str_contains($ep->video_url, 'master.m3u8'); + $type = $isHls ? 'hls' : 'mp4'; + $sources->push(['quality'=>'Auto','url'=>$ep->video_url,'type'=>$type]); + } + if ($ep->video_url_1080 ?? null) $sources->push(['quality'=>'1080p','url'=>$ep->video_url_1080,'type'=>'mp4']); + if ($ep->video_url_720 ?? null) $sources->push(['quality'=>'720p','url'=>$ep->video_url_720,'type'=>'mp4']); + if ($ep->video_url_480 ?? null) $sources->push(['quality'=>'480p','url'=>$ep->video_url_480,'type'=>'mp4']); + + // Çok kaynak desteği (video_sources tablosu) + // Her translator/kaynak bir grup → [{key, label, url, type, quality}] + $multiSources = \App\Models\VideoSource::where('episode_id', $ep->id) + ->orderBy('sort_order') + ->get() + ->groupBy(fn($vs) => $vs->translator_id ?: $vs->label) + ->map(function ($group) { + $default = $group->firstWhere('is_default', true) ?? $group->first(); + // Tüm kaliteler (1080p, 720p, vb.) + $qualities = $group->map(fn($vs) => [ + 'quality' => $vs->quality ?: 'Auto', + 'url' => $vs->url, + 'type' => $vs->type ?? 'mp4', + ])->values()->toArray(); + + return [ + 'key' => $default->translator_id + ?: \Illuminate\Support\Str::slug($default->label ?? 'kaynak'), + 'label' => $default->label ?: 'Kaynak', + 'url' => $default->url, + 'type' => $default->type ?? 'mp4', + 'quality' => $default->quality ?: 'Auto', + 'source' => $default->source ?? 'animecix', + 'qualities' => $qualities, + 'is_default'=> (bool) $default->is_default, + ]; + }) + ->values() + ->toArray(); + + // Player settings + $skipSeconds = (int) \App\Models\Setting::get('main_video_skip_seconds', 10); + $wmCoverSeconds = (int) \App\Models\Setting::get('watermark_cover_seconds', 11); + $introEnabled = \App\Models\Setting::get('intro_enabled') == '1'; + $introUrl = $introEnabled ? (\App\Models\Setting::get('intro_video_url') ?: null) : null; + $introSkipAfter = (int) \App\Models\Setting::get('intro_skip_after', 5); + + // AniSkip is fetched via separate /api/aniskip endpoint to avoid blocking video load + $aniSkipData = null; + + // All episodes list for in-player episode switcher + $allEpisodes = $seasonModel->episodes()->where('is_published', true)->orderBy('episode_number') + ->get()->map(fn($e) => [ + 'id' => $e->id, + 'episode_number' => $e->episode_number, + 'season_number' => $season, + 'title' => $e->title, + 'thumbnail_url' => $e->thumbnail_url ?? null, + ]); + + return response()->json([ + 'anime' => ['id'=>$anime->id,'title'=>$anime->title,'slug'=>$anime->slug,'cover_url'=>$anime->coverUrl], + 'season' => ['id'=>$seasonModel->id,'season_number'=>$seasonModel->season_number,'title'=>$seasonModel->title], + 'episode' => $this->episodeResource($ep), + 'sources' => $sources->values(), + 'multi_sources'=> $multiSources, // Çok kaynak (Anizium "4K" + AnimeCix çevirmenler) + 'dub_sources' => $dubSources, + 'subtitles' => $subtitles, + 'episodes' => $allEpisodes, + 'prev_episode' => $prev ? ['season'=>$prev->season?->season_number ?? $season,'episode'=>$prev->episode_number] : null, + 'next_episode' => $next ? ['season'=>$next->season?->season_number ?? $season,'episode'=>$next->episode_number] : null, + 'settings' => [ + 'skip_seconds' => $skipSeconds, + 'wm_cover_seconds' => $wmCoverSeconds, + 'intro_url' => $introUrl, + 'intro_skip_after' => $introSkipAfter, + // AniSkip timestamps (null if not available) + 'aniskip' => $aniSkipData, // {'op':{'start':X,'end':Y}, 'ed':{'start':X,'end':Y}} + ], + ]); + } + + // ── Resources ───────────────────────────────────────────────────────────── + + private function animeResource(Anime $a, bool $full = false): array + { + $base = [ + 'id' => $a->id, + 'title' => $a->title, + 'title_en' => $a->title_en, + 'title_jp' => $a->title_jp, + 'slug' => $a->slug, + 'cover_url' => $a->coverUrl, + 'banner_url' => $a->bannerUrl, + 'type' => $a->type, + 'status' => $a->status, + 'rating' => $a->rating ? (float)$a->rating : null, + 'release_year' => $a->release_year, + 'episode_count' => $a->episode_count, + 'genres' => $a->relationLoaded('genres') + ? $a->genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])->values() + : [], + ]; + + if ($full) { + $base['description'] = $a->description; + $base['studio'] = $a->studio ?? null; + $base['duration'] = $a->duration ?? null; + $base['is_featured'] = $a->is_featured; + + // First episode for "watch now" button + if ($a->relationLoaded('seasons') && $a->seasons->isNotEmpty()) { + $firstSeason = $a->seasons->first(); + $eps = $a->relationLoaded('episodes') ? $a->episodes : $firstSeason->episodes; + $firstEp = $eps->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first(); + $base['first_watch'] = ($firstSeason && $firstEp) ? [ + 'season' => $firstSeason->season_number, + 'episode' => $firstEp->episode_number, + ] : null; + } + } + + return $base; + } + + private function episodeResource(Episode $ep): array + { + return [ + 'id' => $ep->id, + 'episode_number' => $ep->episode_number, + 'title' => $ep->title, + 'thumbnail_url' => $ep->thumbnailUrl ?? null, + 'duration' => $ep->duration, + 'view_count' => $ep->view_count, + 'created_at' => $ep->created_at?->toISOString(), + ]; + } + + private function episodeCardResource(Episode $ep): array + { + return [ + 'id' => $ep->id, + 'episode_number' => $ep->episode_number, + 'season_number' => $ep->season?->season_number, + 'title' => $ep->title, + 'thumbnail_url' => $ep->thumbnailUrl ?? null, + 'created_at' => $ep->created_at?->diffForHumans(), + 'anime' => [ + 'id' => $ep->anime->id, + 'title' => $ep->anime->title, + 'slug' => $ep->anime->slug, + 'cover_url' => $ep->anime->coverUrl, + 'rating' => $ep->anime->rating ? (float)$ep->anime->rating : null, + 'description' => $ep->anime->description, + ], + ]; + } + + // ── AniSkip endpoint ───────────────────────────────────────────────────── + + // GET /api/aniskip/{slug}/{season}/{episode} + // Fully automatic: finds MAL ID by title if missing, caches everything + public function aniSkip(string $slug, int $season, int $episode) + { + $anime = Anime::where('slug', $slug)->first(); + if (!$anime) return response()->json(['aniskip' => null]); + + $seasonModel = $anime->seasons()->where('season_number', $season)->first(); + if (!$seasonModel) return response()->json(['aniskip' => null]); + + $seasonMalId = $seasonModel->mal_id; + + try { + // anime.mal_id yoksa title search (bir kez, cache'lenir) + if (!$anime->mal_id) { + $found = (new \App\Services\JikanService())->searchMalId($anime->title, $anime->title_en, $anime->title_jp); + if ($found) $anime->update(['mal_id' => $found]); + } + + if (!$seasonMalId && $anime->mal_id) { + // S1 için anime.mal_id direkt kullan — Jikan'a gitme + if ($season === 1) { + $seasonMalId = $anime->mal_id; + $seasonModel->update(['mal_id' => $seasonMalId]); + } else { + // Diğer sezonlar: sadece cache'ten bak, yoksa null dön (page load bloke olmasın) + $chain = \Illuminate\Support\Facades\Cache::get("jikan_chain_{$anime->mal_id}"); + if ($chain) { + $seasonMalId = $chain[$season - 1] ?? $chain[0] ?? null; + if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]); + } + } + } + + if ($seasonMalId) { + $data = (new \App\Services\AniSkipService())->getSkipTimes((string)$seasonMalId, $episode); + return response()->json(['aniskip' => $data]); + } + } catch (\Throwable) {} + + return response()->json(['aniskip' => null]); + } + + // ── Skip segments ───────────────────────────────────────────────────────── + + // POST /api/episodes/{episode}/skip-event (anonim OK, rate-limited) + public function recordSkipEvent(Request $request, Episode $episode) + { + $from = (int) $request->input('from_sec', 0); + $to = (int) $request->input('to_sec', 0); + + // Basic sanity: must skip forward at least 5s, not more than 10 min + if ($to <= $from + 4 || ($to - $from) > 600) { + return response()->json(['ok' => false]); + } + + DB::table('episode_skip_events')->insert([ + 'episode_id' => $episode->id, + 'from_sec' => $from, + 'to_sec' => $to, + 'created_at' => now(), + ]); + + return response()->json(['ok' => true]); + } + + // GET /api/episodes/{episode}/skip-segments + // Returns segments where >= 10 users skipped from within a 30-second window + public function skipSegments(Episode $episode) + { + // İntro tespiti: ilk 3 dakika içinde 45-150sn ileri atlama = intro skip + // Kümeleme: 20sn bucket, to_sec standart sapması <= 15sn, en az 2 farklı kullanıcı + $rows = DB::table('episode_skip_events') + ->where('episode_id', $episode->id) + ->where('from_sec', '<', 180) + ->whereRaw('(to_sec - from_sec) BETWEEN 45 AND 150') + ->selectRaw(' + FLOOR(from_sec / 20) * 20 AS bucket_start, + AVG(to_sec) AS avg_to, + STDDEV_POP(to_sec) AS stddev_to, + COUNT(*) AS cnt + ') + ->groupByRaw('FLOOR(from_sec / 20) * 20') + ->havingRaw('cnt >= 2 AND (STDDEV_POP(to_sec) <= 15 OR cnt = 1)') + ->orderBy('cnt', 'desc') + ->limit(1) + ->get(); + + $segments = $rows->map(fn($r) => [ + 'from' => (int) $r->bucket_start, + 'to' => (int) round($r->avg_to), + 'count'=> (int) $r->cnt, + ])->values(); + + return response()->json(['segments' => $segments]); + } + + private function continueWatchingResource($cw): array + { + return [ + 'id' => $cw->id, + 'season_number' => $cw->season_number, + 'episode_number' => $cw->episode_number, + 'percent_complete' => $cw->percent_complete, + 'anime' => $cw->anime ? [ + 'id' => $cw->anime->id, + 'title' => $cw->anime->title, + 'slug' => $cw->anime->slug, + 'cover_url' => \App\Support\MediaUrl::fromStoragePath($cw->anime->cover_image), + ] : null, + ]; + } + + // POST /api/sources/flag-hevc + // Player tarafından çağrılır: HEVC hatası alınan kaynak URL'sini DB'ye işler + public function flagHevc(Request $request) + { + $url = $request->input('url'); + if (!$url) return response()->json(['ok' => false]); + + \App\Models\VideoSource::where('url', $url)->update([ + 'is_hevc' => true, + 'hevc_checked_at' => now(), + ]); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/AuthApiController.php b/app/Http/Controllers/Api/AuthApiController.php new file mode 100644 index 0000000..1e1bf6f --- /dev/null +++ b/app/Http/Controllers/Api/AuthApiController.php @@ -0,0 +1,150 @@ +validate([ + 'name' => 'required|string|max:100', + 'username' => 'required|string|max:50|unique:users|alpha_dash', + 'email' => 'required|email|unique:users', + 'password' => 'required|string|min:6|confirmed', + ]); + + $user = User::create([ + 'name' => $data['name'], + 'username' => $data['username'], + 'email' => $data['email'], + 'password' => $data['password'], + 'role' => 'user', + 'membership' => 'free', + ]); + + $token = $user->createToken('animexe-app')->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => $this->userResource($user), + ], 201); + } + + public function login(Request $request) + { + $data = $request->validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + $user = User::where('email', $data['email'])->first(); + + if (!$user || !Hash::check($data['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['E-posta veya şifre hatalı.'], + ]); + } + + if ($user->is_banned) { + return response()->json([ + 'message' => 'Hesabınız yasaklandı. Sebep: ' . ($user->ban_reason ?? 'Belirtilmedi'), + ], 403); + } + + $token = $user->createToken('animexe-app')->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => $this->userResource($user), + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + return response()->json(['message' => 'Çıkış yapıldı.']); + } + + public function me(Request $request) + { + return response()->json(['user' => $this->userResource($request->user())]); + } + + public function saveFcmToken(Request $request) + { + $data = $request->validate(['token' => 'required|string|max:500']); + $request->user()->update(['fcm_token' => $data['token']]); + return response()->json(['ok' => true]); + } + + public function updateProfile(Request $request) + { + $user = $request->user(); + + $request->validate([ + 'name' => 'sometimes|string|max:100', + 'username' => 'sometimes|string|max:50|unique:users,username,' . $user->id . '|alpha_dash', + 'password' => 'sometimes|string|min:6|confirmed', + 'bio' => 'sometimes|nullable|string|max:300', + 'website' => 'sometimes|nullable|string|max:100', + 'twitter' => 'sometimes|nullable|string|max:50', + 'instagram' => 'sometimes|nullable|string|max:50', + 'discord' => 'sometimes|nullable|string|max:50', + 'avatar' => 'sometimes|nullable|image|max:3072', + 'banner' => 'sometimes|nullable|image|max:6144', + ]); + + $data = $request->only(['name', 'username', 'bio', 'website', 'twitter', 'instagram', 'discord']); + $data = array_filter($data, fn($v) => $v !== null); + + if ($request->filled('password')) { + $data['password'] = bcrypt($request->input('password')); + } + + if ($request->hasFile('avatar')) { + $data['avatar'] = $request->file('avatar')->store('avatars', 'public'); + } + + if ($request->hasFile('banner')) { + $data['banner_image'] = $request->file('banner')->store('banners', 'public'); + } + + if (!empty($data)) { + $user->update($data); + } + + return response()->json(['user' => $this->userResource($user->fresh())]); + } + + private function userResource(User $user): array + { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'email' => $user->email, + 'bio' => $user->bio, + 'website' => $user->website, + 'twitter' => $user->twitter, + 'instagram' => $user->instagram, + 'discord' => $user->discord, + 'avatar' => $user->avatar + ? (\App\Support\MediaUrl::fromStoragePath($user->avatar)) + : null, + 'banner_image' => $user->banner_image + ? (\App\Support\MediaUrl::fromStoragePath($user->banner_image)) + : null, + 'role' => $user->role, + 'membership' => $user->membership, + 'is_premium' => $user->isPremium(), + 'premium_expires_at' => $user->premium_expires_at?->toISOString(), + 'created_at' => $user->created_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Controllers/Api/CommentApiController.php b/app/Http/Controllers/Api/CommentApiController.php new file mode 100644 index 0000000..af2ac10 --- /dev/null +++ b/app/Http/Controllers/Api/CommentApiController.php @@ -0,0 +1,108 @@ +input('anime_id'); + $episodeId = $request->input('episode_id'); + + $query = Comment::with('user:id,name,username,avatar') + ->where('status', 'approved') + ->orderByDesc('is_pinned') + ->orderByDesc('created_at'); + + if ($episodeId) { + $query->where('commentable_type', \App\Models\Episode::class) + ->where('commentable_id', $episodeId); + } elseif ($animeId) { + $query->where('commentable_type', Anime::class) + ->where('commentable_id', $animeId); + } + + $items = $query->paginate(20); + $userId = $request->user()?->id; + + return response()->json([ + 'data' => collect($items->items())->map(fn($c) => $this->fmt($c, $userId)), + 'total' => $items->total(), + 'last_page' => $items->lastPage(), + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'nullable|exists:animes,id', + 'episode_id' => 'nullable|exists:episodes,id', + 'body' => 'required|string|max:1000', + 'gif_url' => 'nullable|url|max:500', + ]); + + if (empty($data['anime_id']) && empty($data['episode_id'])) { + return response()->json(['error' => 'anime_id veya episode_id gerekli.'], 422); + } + + $isEpisode = !empty($data['episode_id']); + $comment = Comment::create([ + 'user_id' => $request->user()->id, + 'commentable_type' => $isEpisode ? \App\Models\Episode::class : Anime::class, + 'commentable_id' => $isEpisode ? $data['episode_id'] : $data['anime_id'], + 'content' => $data['body'], + 'gif_url' => $data['gif_url'] ?? null, + 'status' => 'approved', + ]); + + $comment->load('user:id,name,username,avatar'); + + return response()->json($this->fmt($comment, $request->user()->id), 201); + } + + public function like(Request $request, Comment $comment) + { + $userId = $request->user()->id; + $existing = CommentLike::where('user_id', $userId) + ->where('comment_id', $comment->id)->first(); + + if ($existing) { + $existing->delete(); + $comment->decrement('like_count'); + return response()->json(['liked' => false, 'likes' => $comment->fresh()->like_count]); + } + + CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]); + $comment->increment('like_count'); + return response()->json(['liked' => true, 'likes' => $comment->fresh()->like_count]); + } + + private function fmt(Comment $c, ?int $userId): array + { + return [ + 'id' => $c->id, + 'body' => $c->content, + 'gif_url' => $c->gif_url, + 'likes_count' => $c->like_count ?? 0, + 'is_pinned' => $c->is_pinned ?? false, + 'created_at' => $c->created_at?->diffForHumans(), + 'user_liked' => $userId + ? CommentLike::where('user_id', $userId)->where('comment_id', $c->id)->exists() + : false, + 'user' => $c->user ? [ + 'id' => $c->user->id, + 'name' => $c->user->name, + 'username' => $c->user->username, + 'avatar' => $c->user->avatar + ? \App\Support\MediaUrl::fromStoragePath($c->user->avatar) + : null, + ] : null, + ]; + } +} diff --git a/app/Http/Controllers/Api/ImportApiController.php b/app/Http/Controllers/Api/ImportApiController.php new file mode 100644 index 0000000..8d9672c --- /dev/null +++ b/app/Http/Controllers/Api/ImportApiController.php @@ -0,0 +1,1100 @@ +first(); + if ($anime) return $anime; + } + + // 2. slug + $slug = Str::slug($data['title'] ?? ''); + if ($slug) { + $anime = Anime::where('slug', $slug)->first(); + if ($anime) { + // mal_id eksikse güncelle + if (!empty($data['mal_id']) && !$anime->mal_id) { + $anime->update(['mal_id' => $data['mal_id']]); + } + return $anime; + } + } + + // 3. Büyük/küçük harf duyarsız başlık + title_en eşleşmesi + $lowerTitle = strtolower(trim($data['title'] ?? '')); + if ($lowerTitle) { + $anime = Anime::whereRaw('LOWER(title) = ?', [$lowerTitle]) + ->orWhereRaw('LOWER(title_en) = ?', [$lowerTitle]) + ->first(); + if ($anime) { + if (!empty($data['mal_id']) && !$anime->mal_id) { + $anime->update(['mal_id' => $data['mal_id']]); + } + return $anime; + } + } + + // 4. Bulunamadı → yeni oluştur + return Anime::create([ + 'title' => $data['title'], + 'title_en' => $data['title_en'] ?? '', + 'slug' => $slug ?: Str::slug($data['title'] ?? 'anime-' . uniqid()), + 'type' => $data['type'] ?? 'series', + 'status' => 'ongoing', + 'is_published' => false, + 'cover_image' => $data['cover'] ?? null, + 'mal_id' => $data['mal_id'] ?? null, + ]); + } + + // ── Auto-Import API'leri ───────────────────────────────────────────────── + + /** + * Programatik job oluşturma. + * AnimeCix ve Anizium her iki kaynak için tek endpoint. + */ + public function createJob(Request $request) + { + $source = $request->input('source', 'anizium'); + + // ── AnimeCix job ────────────────────────────────────────────────────── + if ($source === 'animecix') { + if ($request->has('year') && $request->year !== null) { + $request->merge(['year' => (string) $request->year]); + } + + $data = $request->validate([ + 'animecix_title_id' => 'required|string|max:50', + 'slug' => 'required|string|max:300', + 'title' => 'required|string|max:300', + 'title_en' => 'nullable|string|max:300', + 'cover' => 'nullable|string|max:500', + 'episode_count' => 'nullable|integer', + 'type' => 'nullable|string|max:30', + 'year' => 'nullable|string|max:10', + 'genres' => 'nullable|array', + 'mal_id' => 'nullable|integer', + 'priority' => 'nullable|integer|min:0|max:2', + ]); + + // Dedup — aynı title zaten aktif/bitti mi? + $existing = ImportJob::where('source', 'animecix') + ->where('animecix_title_id', $data['animecix_title_id']) + ->whereIn('status', ['pending', 'fetching', 'done']) + ->latest()->first(); + + if ($existing) { + return response()->json([ + 'job_id' => $existing->id, + 'status' => 'existing', + ]); + } + + // Anime bul veya oluştur (unified matcher) + $anime = $this->findOrCreateAnime([ + 'mal_id' => $data['mal_id'] ?? null, + 'title' => $data['title'], + 'title_en' => $data['title_en'] ?? '', + 'slug' => Str::slug($data['title']), + 'type' => $data['type'] ?: 'series', + 'cover' => $data['cover'] ?? null, + ]); + + // Priority: request'ten geliyorsa kullan, yoksa otomatik hesapla + $priority = (int) ($data['priority'] ?? ImportJob::PRIORITY_NEW); + if (!isset($data['priority']) && !$anime->wasRecentlyCreated) { + $hasAnizium = VideoSource::whereHas('episode', fn($q) => $q->where('anime_id', $anime->id)) + ->where('source', 'anizium')->exists(); + $priority = $hasAnizium ? ImportJob::PRIORITY_CROSSFILL : ImportJob::PRIORITY_NEW; + } + + $job = ImportJob::create([ + 'source' => 'animecix', + 'animecix_title_id' => $data['animecix_title_id'], + 'animecix_slug' => $data['slug'], + 'anime_title' => $data['title'], + 'anime_id' => $anime->id, + 'status' => 'pending', + 'priority' => $priority, + ]); + + // AniList resimlerini arka planda doldur + if (empty($anime->cover_image) || empty($anime->banner_image)) { + dispatch(function () use ($anime) { + try { (new \App\Services\AniListService())->fillImages($anime->fresh()); } + catch (\Throwable) {} + })->afterResponse(); + } + + return response()->json(['job_id' => $job->id, 'status' => 'created', 'anime_id' => $anime->id], 201); + } + + // ── Anizium job ─────────────────────────────────────────────────────── + $data = $request->validate([ + 'source_url' => 'required|string|max:500', + 'anime_title' => 'required|string|max:300', + 'watch_id' => 'required|string|max:50', + 'season_ranges' => 'nullable|array', + 'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1', + 'priority' => 'nullable|integer|min:0|max:2', + ]); + + // Aktif job var mı? + $active = ImportJob::where('watch_id', $data['watch_id']) + ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading']) + ->latest()->first(); + + if ($active) { + return response()->json(['job_id' => $active->id, 'status' => 'existing', 'msg' => 'Aktif job zaten var.']); + } + + // Daha önce bitti mi? (anime_id'yi al) + $prevDone = ImportJob::where('watch_id', $data['watch_id']) + ->where('status', 'done')->whereNotNull('anime_id')->latest()->first(); + + // Anime eşleştir (watch_id'den tanınan anime_id varsa kullan, yoksa title arama) + $anime = null; + if ($prevDone?->anime_id) { + $anime = Anime::find($prevDone->anime_id); + } + if (!$anime) { + // Başlık ile mevcut anime bul (farklı kaynaktan yüklenmiş olabilir) + $slug = Str::slug($data['anime_title']); + $lower = strtolower(trim($data['anime_title'])); + $anime = Anime::where('slug', $slug) + ->orWhereRaw('LOWER(title) = ?', [$lower]) + ->orWhereRaw('LOWER(title_en) = ?', [$lower]) + ->first(); + } + + // Priority: request'ten geliyorsa kullan, yoksa otomatik hesapla + $aniziumPriority = (int) ($data['priority'] ?? ImportJob::PRIORITY_NEW); + if (!isset($data['priority']) && $anime?->id) { + $hasAnimecix = VideoSource::whereHas('episode', fn($q) => $q->where('anime_id', $anime->id)) + ->where('source', 'animecix')->exists(); + $aniziumPriority = $hasAnimecix ? ImportJob::PRIORITY_CROSSFILL : ImportJob::PRIORITY_NEW; + } + + $job = ImportJob::create([ + 'source' => 'anizium', + 'source_url' => $data['source_url'], + 'anime_title' => $data['anime_title'], + 'watch_id' => $data['watch_id'], + 'status' => 'pending', + 'anime_id' => $anime?->id, + 'season_ranges' => $data['season_ranges'] ?? null, + 'priority' => $aniziumPriority, + ]); + + return response()->json(['job_id' => $job->id, 'status' => 'created', 'priority' => $aniziumPriority], 201); + } + + // ── Anime arama endpoint'i — Python botları için ────────────────────────── + // GET /api/import/anime/lookup?mal_id=xxx OR ?title=yyy OR ?slug=zzz + + public function animeLookup(Request $request) + { + // 1. mal_id + if ($mal_id = $request->input('mal_id')) { + $anime = Anime::where('mal_id', (int) $mal_id)->first(); + if ($anime) { + return response()->json([ + 'found' => true, + 'anime_id' => $anime->id, + 'title' => $anime->title, + 'mal_id' => $anime->mal_id, + ]); + } + } + + // 2. Slug veya başlık + if ($title = $request->input('title')) { + $slug = Str::slug($title); + $lower = strtolower(trim($title)); + $anime = Anime::where('slug', $slug) + ->orWhereRaw('LOWER(title) = ?', [$lower]) + ->orWhereRaw('LOWER(title_en) = ?', [$lower]) + ->first(); + if ($anime) { + return response()->json([ + 'found' => true, + 'anime_id' => $anime->id, + 'title' => $anime->title, + 'mal_id' => $anime->mal_id, + ]); + } + } + + return response()->json(['found' => false, 'anime_id' => null]); + } + + // ── Animecix: bekleyen job listesi ──────────────────────────────────────── + public function animecixPendingJobs() + { + try { + $jobs = ImportJob::where('source', 'animecix') + ->where('status', 'pending') + ->orderByDesc('priority') + ->orderBy('id') + ->limit(20) + ->get(['id', 'animecix_title_id', 'animecix_slug', 'anime_title', 'anime_id', 'priority']); + } catch (\Throwable) { + $jobs = ImportJob::where('source', 'animecix') + ->where('status', 'pending') + ->orderBy('id') + ->limit(20) + ->get(['id', 'animecix_title_id', 'animecix_slug', 'anime_title', 'anime_id']); + } + + return response()->json(['jobs' => $jobs, 'count' => $jobs->count()]); + } + + // ── Animecix: episode'lara video kaynakları kaydet ─────────────────────── + public function saveVideoSources(Request $request, Episode $episode) + { + $data = $request->validate([ + 'sources' => 'required|array|min:1', + 'sources.*.label' => 'nullable|string|max:120', + 'sources.*.url' => 'required|string|max:2000', + 'sources.*.type' => 'nullable|in:mp4,hls,embed', + 'sources.*.quality' => 'nullable|string|max:20', + 'sources.*.translator_id' => 'nullable|string|max:60', + 'sources.*.sort' => 'nullable|integer', + ]); + + // Önceki AnimeCix kaynaklarını sil (idempotent yeniden çalıştırma) + VideoSource::where('episode_id', $episode->id)->where('source', 'animecix')->delete(); + + // AnimeCix kaynakları eklendiğinde Anizium '4K' kaynağını secondary yap + VideoSource::where('episode_id', $episode->id) + ->where('source', 'anizium') + ->update(['is_default' => false, 'sort_order' => 99]); + + $isDefault = true; + foreach ($data['sources'] as $idx => $src) { + VideoSource::create([ + 'episode_id' => $episode->id, + 'label' => $src['label'] ?? '', + 'url' => $src['url'], + 'type' => $src['type'] ?? 'mp4', + 'quality' => $src['quality'] ?? '', + 'translator_id' => $src['translator_id'] ?? null, + 'sort_order' => $src['sort'] ?? $idx, + 'is_default' => $isDefault, + 'source' => 'animecix', + ]); + $isDefault = false; + + // İlk AnimeCix kaynağını episode video_url olarak da kaydet + if ($idx === 0) { + $url = $src['url']; + $type = $src['type'] ?? 'mp4'; + if ($type === 'mp4') { + $episode->update(['video_url' => $url, 'source' => 'animecix']); + } elseif ($type === 'hls') { + $episode->update(['m3u8_url' => $url, 'source' => 'animecix']); + } + } + } + + // Anime yayınla (ilk bölüm geldiğinde) + if ($episode->anime_id) { + Anime::where('id', $episode->anime_id)->where('is_published', false)->update(['is_published' => true]); + } + + return response()->json(['ok' => true, 'saved' => count($data['sources'])]); + } + + /** + * Import edilmiş tüm watch_id'leri döndürür (discover.py karşılaştırması için). + * NOT: Artık sadece failed-olmayanlar "mevcut" sayılır. + */ + public function importedIds() + { + $ids = ImportJob::whereNotNull('watch_id') + ->where('watch_id', '!=', '') + ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading', 'done']) + ->pluck('watch_id') + ->map(fn($id) => (string) $id) + ->unique() + ->values(); + + return response()->json(['watch_ids' => $ids, 'count' => $ids->count()]); + } + + /** + * Animexe'deki tüm yayınlanan anime başlıklarını döndürür. + */ + public function importedTitles() + { + $titles = Anime::where('is_published', true) + ->pluck('title') + ->filter() + ->unique() + ->values(); + + return response()->json(['titles' => $titles, 'count' => $titles->count()]); + } + + /** + * Bot 3 güncelleme botu için: Anizium watch_id'si olan TÜM animeleri döndür. + * ongoing/completed/finished fark etmez — her anime eksik bölüm kontrolüne tabi. + * Her animenin mevcut sezon/bölüm durumu da dahil. + */ + public function allAniziumAnimes() + { + $animes = ImportJob::where('import_jobs.status', 'done') + ->whereNotNull('import_jobs.watch_id') + ->whereNotNull('import_jobs.anime_id') + ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') + ->select( + 'import_jobs.watch_id', + 'import_jobs.anime_title', + 'import_jobs.anime_id', + 'animes.status as anime_status' + ) + ->groupBy('import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id', 'animes.status') + ->get(); + + $result = $animes->map(function ($a) { + $seasonData = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $a->anime_id) + ->where('episodes.is_published', true) + ->select( + 'seasons.season_number', + \DB::raw('MAX(episodes.episode_number) as max_episode'), + \DB::raw('COUNT(*) as episode_count') + ) + ->groupBy('seasons.season_number') + ->orderBy('seasons.season_number') + ->get(); + + $seasons = []; + foreach ($seasonData as $s) { + $seasons[(string) $s->season_number] = [ + 'count' => (int) $s->episode_count, + 'max' => (int) $s->max_episode, + ]; + } + + return [ + 'watch_id' => $a->watch_id, + 'anime_title' => $a->anime_title, + 'anime_id' => (int) $a->anime_id, + 'anime_status' => $a->anime_status, + 'seasons' => $seasons, + ]; + }); + + return response()->json(['animes' => $result, 'count' => $result->count()]); + } + + // ── Bot ayarlarını döndür ───────────────────────────────────────────────── + public function settings() + { + $get = fn($key) => \App\Models\Setting::where('key', $key)->value('value') ?? ''; + + return response()->json([ + 'bunnycdn' => [ + 'zone' => $get('bunnycdn_zone'), + 'api_key' => $get('bunnycdn_api_key'), + 'pull_url' => $get('bunnycdn_pull_url'), + ], + ]); + } + + // Bağlantı testi + public function test() + { + $pendingBySource = ImportJob::where('status', 'pending') + ->selectRaw('COALESCE(source, "anizium") as source, COUNT(*) as cnt') + ->groupBy('source') + ->pluck('cnt', 'source'); + + return response()->json([ + 'ok' => true, + 'message' => 'Laravel API erişilebilir', + 'db' => \DB::connection()->getDatabaseName(), + 'pending' => ImportJob::where('status', 'pending')->count(), + 'pending_anizium' => (int) ($pendingBySource['anizium'] ?? 0), + 'pending_animecix' => (int) ($pendingBySource['animecix'] ?? 0), + 'total' => ImportJob::count(), + 'timestamp' => now()->toDateTimeString(), + ]); + } + + // Dashboard istatistikleri + public function stats() + { + $counts = ImportJob::selectRaw('status, COUNT(*) as cnt') + ->groupBy('status') + ->pluck('cnt', 'status'); + + $byStatus = [ + 'pending' => (int) ($counts['pending'] ?? 0), + 'fetching' => (int) ($counts['fetching'] ?? 0), + 'downloading' => (int) ($counts['downloading'] ?? 0), + 'uploading' => (int) ($counts['uploading'] ?? 0), + 'done' => (int) ($counts['done'] ?? 0), + 'failed' => (int) ($counts['failed'] ?? 0), + ]; + + $active = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->latest()->first(); + + $ongoingCount = ImportJob::where('import_jobs.status', 'done') + ->whereNotNull('import_jobs.anime_id') + ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') + ->where('animes.status', 'ongoing') + ->distinct('import_jobs.watch_id') + ->count('import_jobs.watch_id'); + + return response()->json([ + 'total' => array_sum($byStatus), + 'by_status' => $byStatus, + 'ongoing_count' => $ongoingCount, + 'active_job' => $active ? [ + 'id' => $active->id, + 'title' => $active->anime_title, + 'status' => $active->status, + 'current_step' => $active->current_step, + 'total_episodes' => (int) ($active->total_episodes ?? 0), + 'done_episodes' => (int) ($active->done_episodes ?? 0), + 'progress_pct' => $active->progress_percent, + ] : null, + ]); + } + + // Python: belirli bir job'u al + public function getJob(ImportJob $job) + { + return response()->json(['job' => $job]); + } + + /** + * Python: bekleyen job var mı? — DB lock ile atomik al. + * Her kaynak kendi job'larını alır; çapraz engelleme KALDIRILDI. + * AnimeCix kendi kuyruğunu /animecix/pending ile alıyor. + * Bu endpoint sadece Anizium (ve untagged legacy) job'larını döndürür. + */ + public function nextJob() + { + $job = \DB::transaction(function () { + // Priority sırası: 2 (cross-fill) → 1 (ongoing) → 0 (yeni keşif) + // priority kolonu henüz yoksa (migration çalıştırılmadıysa) sadece id sıralaması + try { + $job = ImportJob::where('status', 'pending') + ->where(fn($q) => + $q->where('source', 'anizium') + ->orWhere('source', '') + ->orWhereNull('source') + ) + ->orderByDesc('priority') + ->orderByRaw('CASE WHEN season_ranges IS NOT NULL THEN 1 ELSE 0 END DESC') + ->orderBy('id') + ->lockForUpdate() + ->first(); + } catch (\Throwable) { + $job = ImportJob::where('status', 'pending') + ->where(fn($q) => + $q->where('source', 'anizium') + ->orWhere('source', '') + ->orWhereNull('source') + ) + ->orderByRaw('CASE WHEN season_ranges IS NOT NULL THEN 1 ELSE 0 END DESC') + ->orderBy('id') + ->lockForUpdate() + ->first(); + } + + if ($job) { + $job->update(['status' => 'fetching']); + } + return $job; + }); + + return response()->json(['job' => $job]); + } + + // Daemon: import edilmiş ongoing animeleri döndür (yeni bölüm kontrolü için) + public function ongoingAnimes() + { + $animes = ImportJob::where('import_jobs.status', 'done') + ->whereNotNull('import_jobs.watch_id') + ->whereNotNull('import_jobs.anime_id') + ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') + ->where('animes.status', 'ongoing') + ->select( + 'import_jobs.watch_id', + 'import_jobs.anime_title', + 'import_jobs.anime_id' + ) + ->groupBy('import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id') + ->get(); + + $result = $animes->map(function ($a) { + $seasonData = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $a->anime_id) + ->where('episodes.is_published', true) + ->select( + 'seasons.season_number', + \DB::raw('MAX(episodes.episode_number) as max_episode'), + \DB::raw('COUNT(*) as episode_count') + ) + ->groupBy('seasons.season_number') + ->orderBy('seasons.season_number') + ->get(); + + $seasons = []; + foreach ($seasonData as $s) { + $seasons[(string) $s->season_number] = [ + 'count' => (int) $s->episode_count, + 'max' => (int) $s->max_episode, + ]; + } + + return [ + 'watch_id' => $a->watch_id, + 'anime_title' => $a->anime_title, + 'anime_id' => (int) $a->anime_id, + 'seasons' => $seasons, + ]; + }); + + return response()->json(['animes' => $result, 'count' => $result->count()]); + } + + // Python: job durumunu güncelle + public function updateStatus(Request $request, ImportJob $job) + { + $data = $request->validate([ + 'status' => 'sometimes|in:pending,fetching,downloading,uploading,done,failed', + 'current_step' => 'nullable|string', + 'total_episodes' => 'nullable|integer', + 'done_episodes' => 'nullable|integer', + 'failed_episodes' => 'nullable|integer', + 'error_log' => 'nullable|string', + ]); + + $update = array_intersect_key($data, array_flip(array_keys($request->all()))); + if (!empty($update)) { + $job->update($update); + } + + return response()->json(['ok' => true]); + } + + // Python: bir bölüm tamamlandı, DB'ye kaydet + public function saveEpisode(Request $request, ImportJob $job) + { + $data = $request->validate([ + 'season' => 'required|integer|min:1', + 'episode' => 'required|integer|min:0', + 'title' => 'nullable|string', + 'description' => 'nullable|string', + 'duration' => 'nullable|integer', + 'video_url' => 'nullable|string', + 'm3u8_url' => 'nullable|string', + 'bunny_video_id' => 'nullable|string', + 'source_url' => 'nullable|string', + 'thumbnail' => 'nullable|string', + 'available_dubs' => 'nullable|array', + 'available_dubs.*'=> 'string|max:32', + 'embed_source' => 'nullable|string|max:32', + 'extra_sources' => 'nullable|array', + 'extra_sources.*.url' => 'required|string|max:2000', + 'extra_sources.*.quality' => 'nullable|string|max:20', + 'extra_sources.*.label' => 'nullable|string|max:60', + ]); + + $isAnizium = in_array($job->source ?? 'anizium', ['anizium', '', null], true) + || is_null($job->source); + + // Anime bul veya oluştur + if ($job->anime_id) { + $anime = Anime::find($job->anime_id); + } else { + $baseTitle = $job->anime_title ?: "Anime CDN-{$job->cdn_id}"; + $slug = Str::slug($baseTitle) . '-' . ($job->cdn_id ?: $job->id); + + $anime = Anime::firstOrCreate( + ['slug' => $slug], + [ + 'title' => $baseTitle, + 'type' => 'series', + 'status' => 'ongoing', + 'is_published' => true, + ] + ); + $job->update(['anime_id' => $anime->id]); + + // Auto-fetch MAL ID (fire-and-forget) + if (!$anime->mal_id) { + dispatch(function () use ($anime) { + try { + $jikan = new JikanService(); + $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp); + if ($malId) { + $anime->update(['mal_id' => $malId]); + $chain = $jikan->fetchSeasonMalIds($malId); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (!$season->mal_id && isset($chain[$i])) { + $season->update(['mal_id' => $chain[$i]]); + } + } + (new \App\Services\AniListService())->fillImages($anime->fresh()); + } + } catch (\Throwable) {} + })->afterResponse(); + } + + if (empty($anime->cover_image) || empty($anime->banner_image)) { + dispatch(function () use ($anime) { + try { (new \App\Services\AniListService())->fillImages($anime->fresh()); } + catch (\Throwable) {} + })->afterResponse(); + } + + if (Setting::get('ai_auto_seo') === '1' && empty($anime->seo_title)) { + dispatch(function () use ($anime) { + try { + $ai = new \App\Services\DeepSeekService(); + $result = $ai->generateAnimeSeoMeta($anime->fresh(['genres'])); + if ($result) { + $anime->update([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + } + } catch (\Throwable) {} + })->afterResponse(); + } + } + + // Sezon bul/oluştur + $season = Season::firstOrCreate( + ['anime_id' => $anime->id, 'season_number' => $data['season']], + ['is_published' => true] + ); + + if (!$season->mal_id && $anime->mal_id) { + dispatch(function () use ($anime, $season) { + try { + $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); + $idx = $season->season_number - 1; + if (isset($chain[$idx])) $season->update(['mal_id' => $chain[$idx]]); + } catch (\Throwable) {} + })->afterResponse(); + } + + // Mevcut episode var mı? (başka kaynaktan yüklenmiş olabilir) + $existingEpisode = Episode::where('season_id', $season->id) + ->where('episode_number', $data['episode']) + ->first(); + + // Başlık stratejisi: + // Anizium → her zaman başlığı set eder (kullanıcı isteği: başlık aniziumdan gelsin) + // AnimeCix → sadece mevcut başlık boşsa set eder + $titleValue = $data['title'] ?? null; + if (!$isAnizium && $existingEpisode && $existingEpisode->title) { + $titleValue = $existingEpisode->title; // AnimeCix mevcut başlığı ezip geçmez + } + + $episodeValues = [ + 'anime_id' => $anime->id, + 'title' => $titleValue, + 'description' => $data['description'] ?? null, + 'duration' => $data['duration'] ?? null, + 'source_url' => $data['source_url'] ?? null, + 'thumbnail' => $data['thumbnail'] ?? null, + 'status' => 'published', + 'is_published' => true, + ]; + + // video_url / m3u8_url sadece Anizium koyar (AnimeCix video_sources'tan gider) + if ($isAnizium) { + $episodeValues['video_url'] = $data['video_url'] ?? null; + $episodeValues['m3u8_url'] = $data['m3u8_url'] ?? null; + $episodeValues['bunny_video_id'] = $data['bunny_video_id'] ?? null; + $episodeValues['available_dubs'] = isset($data['available_dubs']) ? json_encode($data['available_dubs']) : null; + $episodeValues['source'] = isset($data['bunny_video_id']) ? 'bunnycdn' : ($data['embed_source'] ?? 'anizium'); + } + + try { + Episode::updateOrCreate( + ['season_id' => $season->id, 'episode_number' => $data['episode']], + $episodeValues + ); + } catch (\Illuminate\Database\QueryException $e) { + if (str_contains($e->getMessage(), 'available_dubs')) { + unset($episodeValues['available_dubs']); + Episode::updateOrCreate( + ['season_id' => $season->id, 'episode_number' => $data['episode']], + $episodeValues + ); + } else { + throw $e; + } + } + + $savedEp = Episode::where('season_id', $season->id) + ->where('episode_number', $data['episode']) + ->first(); + + // ── Anizium bölümlerini video_sources tablosuna kaydet ── + if ($isAnizium && $savedEp) { + $vsUrl = $data['video_url'] ?? $data['m3u8_url'] ?? null; + $vsType = (!empty($data['video_url'])) ? 'mp4' + : (!empty($data['m3u8_url']) ? 'hls' : null); + + if ($vsUrl && $vsType) { + $hasAnimecix = VideoSource::where('episode_id', $savedEp->id) + ->where('source', 'animecix') + ->exists(); + + // Mevcut anizium kaynaklarını temizle, yeniden ekle + VideoSource::where('episode_id', $savedEp->id) + ->where('source', 'anizium') + ->delete(); + + // Ana kaynak (en yüksek kalite) + VideoSource::create([ + 'episode_id' => $savedEp->id, + 'source' => 'anizium', + 'label' => '4K', + 'url' => $vsUrl, + 'type' => $vsType, + 'quality' => '4K', + 'sort_order' => $hasAnimecix ? 99 : 0, + 'is_default' => !$hasAnimecix, + ]); + + // Yedek kaliteler (720p, 480p vs. — HEVC failse browser bunları dener) + foreach (($data['extra_sources'] ?? []) as $idx => $src) { + VideoSource::create([ + 'episode_id' => $savedEp->id, + 'source' => 'anizium', + 'label' => $src['label'] ?? ($src['quality'] ?? 'Yedek'), + 'url' => $src['url'], + 'type' => 'hls', + 'quality' => $src['quality'] ?? null, + 'sort_order' => ($hasAnimecix ? 99 : 0) + $idx + 1, + 'is_default' => false, + ]); + } + } + } + + $anime->update(['episode_count' => $anime->episodes()->count()]); + $job->increment('done_episodes'); + + // Anime yayınla + Anime::where('id', $anime->id)->where('is_published', false)->update(['is_published' => true]); + + // Auto açıklama üretimi (Anizium için) + if ($isAnizium && Setting::get('ai_auto_description') === '1' && $savedEp && empty($savedEp->description)) { + $ai = new DeepSeekService(); + $desc = $ai->generateEpisodeDescription($anime->title, $data['episode'], $data['title'] ?? ''); + if ($desc) $savedEp->update(['description' => $desc]); + } + + return response()->json(['ok' => true, 'anime_id' => $anime->id, 'episode_id' => $savedEp?->id]); + } + + /** + * Python: job için tamamlanmış bölümleri döndür (resume desteği). + * + * Her kaynak sadece KENDİ kaydettiği bölümleri "done" sayar. + * Anizium → video_sources.source='anizium' olan bölümler + * AnimeCix → video_sources.source='animecix' olan bölümler + * Legacy (source belirsiz) → eski davranış (is_published=true) + */ + public function doneEpisodes(ImportJob $job) + { + if (!$job->anime_id) { + return response()->json(['done' => (object)[]]); + } + + $source = $job->source ?? 'anizium'; + + if (in_array($source, ['anizium', 'animecix'], true)) { + // Kaynak bazlı: sadece bu kaynağın video_sources kayıtları olan bölümler + $rows = \DB::table('video_sources') + ->join('episodes', 'episodes.id', '=', 'video_sources.episode_id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $job->anime_id) + ->where('video_sources.source', $source) + ->select('seasons.season_number as s', 'episodes.episode_number as e') + ->distinct() + ->get(); + } else { + // Legacy: is_published=true olan tüm bölümler + $rows = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $job->anime_id) + ->where('episodes.is_published', true) + ->select('seasons.season_number as s', 'episodes.episode_number as e') + ->get(); + } + + $done = []; + foreach ($rows as $r) { + $done[(string)$r->s][(string)$r->e] = true; + } + + return response()->json(['done' => $done ?: (object)[]]); + } + + // Python: bir bölüme altyazı kaydet + public function saveSubtitle(Request $request, ImportJob $job) + { + $data = $request->validate([ + 'season' => 'required|integer|min:1', + 'episode' => 'required|integer|min:1', + 'language' => 'required|string|max:10', + 'label' => 'required|string|max:50', + 'url' => 'required|string', + 'is_default' => 'boolean', + ]); + + $season = Season::where('anime_id', $job->anime_id) + ->where('season_number', $data['season'])->first(); + if (!$season) { + return response()->json(['ok' => false, 'msg' => 'Season bulunamadı'], 404); + } + + $episode = Episode::where('season_id', $season->id) + ->where('episode_number', $data['episode'])->first(); + if (!$episode) { + return response()->json(['ok' => false, 'msg' => 'Episode bulunamadı'], 404); + } + + Subtitle::updateOrCreate( + ['episode_id' => $episode->id, 'language' => $data['language']], + ['label' => $data['label'], 'url' => $data['url'], 'is_default' => $data['is_default'] ?? false] + ); + + return response()->json(['ok' => true]); + } + + // Anizium kaynaklı tüm bölümleri watch_id bazında döndür (altyazı yenileme için) + // ?only_missing_subs=1 → subtitles tablosunda kaydı olmayan bölümler + // ?fix_anizium_subs=1 → altyazısı var ama URL'i hâlâ ham Anizium linki olan bölümler (b-cdn.net değil) + public function aniziumEpisodes(Request $request) + { + $onlyMissing = $request->boolean('only_missing_subs', false); + $fixAniziumSubs = $request->boolean('fix_anizium_subs', false); + + $query = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('episodes.source_url') + ->where('episodes.source_url', 'like', '%anizium.co/watch/%') + ->select( + 'episodes.id as episode_id', + 'seasons.season_number as season', + 'episodes.episode_number as episode', + 'episodes.source_url', + 'episodes.view_count' + ) + ->orderBy('episodes.id'); + + if ($onlyMissing) { + $query->whereNotExists(function ($sub) { + $sub->select(\DB::raw(1)) + ->from('subtitles') + ->whereColumn('subtitles.episode_id', 'episodes.id'); + }); + } elseif ($fixAniziumSubs) { + // Altyazısı var ama en az bir URL b-cdn.net içermiyor (ham Anizium linki) + $query->whereExists(function ($sub) { + $sub->select(\DB::raw(1)) + ->from('subtitles') + ->whereColumn('subtitles.episode_id', 'episodes.id') + ->where('subtitles.url', 'not like', '%b-cdn.net%'); + }); + } + + $rows = $query->get(); + + $grouped = []; + $viewCounts = []; + foreach ($rows as $r) { + if (!preg_match('#anizium\.co/watch/(\w+)#', $r->source_url, $m)) continue; + $wid = $m[1]; + if (!isset($grouped[$wid])) { + $grouped[$wid] = []; + $viewCounts[$wid] = 0; + } + $grouped[$wid][] = [ + 'episode_id' => $r->episode_id, + 'season' => $r->season, + 'episode' => $r->episode, + ]; + $viewCounts[$wid] += (int) ($r->view_count ?? 0); + } + + // Popularity'e göre sırala (en çok izlenen önce) + $sortedAnimes = []; + foreach ($grouped as $wid => $eps) { + $sortedAnimes[$wid] = [ + 'episodes' => $eps, + 'view_count' => $viewCounts[$wid], + ]; + } + uasort($sortedAnimes, fn($a, $b) => $b['view_count'] - $a['view_count']); + + return response()->json([ + 'total' => $rows->count(), + 'animes' => $sortedAnimes, + ]); + } + + // Sağlık kontrolü: Anizium kaynaklı anime + bölüm URL'leri + public function aniziumHealthData() + { + $rows = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->join('animes', 'animes.id', '=', 'episodes.anime_id') + ->join('import_jobs', function ($j) { + $j->on('import_jobs.anime_id', '=', 'animes.id') + ->where('import_jobs.source', 'anizium') + ->where('import_jobs.status', 'done'); + }) + ->where('episodes.is_published', true) + ->whereNotNull('episodes.source_url') + ->where('episodes.source_url', 'like', '%anizium.co/watch/%') + ->select( + 'animes.id as anime_id', + 'animes.title', + 'animes.slug', + 'import_jobs.watch_id', + 'episodes.id as episode_id', + 'seasons.season_number as season', + 'episodes.episode_number as episode', + 'episodes.m3u8_url', + 'episodes.video_url' + ) + ->orderBy('animes.id') + ->orderBy('seasons.season_number') + ->orderBy('episodes.episode_number') + ->get(); + + $animes = []; + foreach ($rows as $r) { + $aid = $r->anime_id; + if (!isset($animes[$aid])) { + $animes[$aid] = [ + 'anime_id' => $aid, + 'title' => $r->title, + 'slug' => $r->slug, + 'watch_id' => $r->watch_id, + 'episodes' => [], + ]; + } + $url = $r->m3u8_url ?: $r->video_url; + if ($url) { + $animes[$aid]['episodes'][] = [ + 'episode_id' => $r->episode_id, + 'season' => $r->season, + 'episode' => $r->episode, + 'url' => $url, + ]; + } + } + + return response()->json([ + 'anime_count' => count($animes), + 'episode_count' => $rows->count(), + 'animes' => array_values($animes), + ]); + } + + // Anime'yi inaktife al (Anizium bozuk, yeniden import edilecek) + public function deactivateAnime(Request $request) + { + $data = $request->validate(['anime_id' => 'required|integer|exists:animes,id']); + + Anime::where('id', $data['anime_id'])->update(['is_published' => false]); + ImportJob::where('anime_id', $data['anime_id']) + ->where('source', 'anizium') + ->update(['status' => 'failed', 'error_log' => 'CDN URL broken — replaced']); + + return response()->json(['ok' => true]); + } + + // Altyazıyı doğrudan episode_id ile kaydet + public function saveSubtitleDirect(Request $request) + { + $data = $request->validate([ + 'episode_id' => 'required|integer|exists:episodes,id', + 'language' => 'required|string|max:10', + 'label' => 'required|string|max:50', + 'url' => 'required|string', + 'is_default' => 'boolean', + ]); + + Subtitle::updateOrCreate( + ['episode_id' => $data['episode_id'], 'language' => $data['language']], + ['label' => $data['label'], 'url' => $data['url'], 'is_default' => $data['is_default'] ?? false] + ); + + return response()->json(['ok' => true]); + } + + // ── Çapraz re-import: yayınlanan animeler için tamamlanmış job'ları döndür ─ + // Kullanım: cross_reimport.py scripti bu endpoint'i çağırır + public function publishedAnimesWithJobs() + { + $animes = Anime::where('is_published', true) + ->with(['importJobs' => fn($q) => $q->where('status', 'done')->select( + 'id', 'anime_id', 'source', 'watch_id', 'animecix_title_id', 'animecix_slug', 'status' + )]) + ->select('id', 'title', 'title_en', 'slug', 'mal_id', 'type', 'status') + ->get() + ->map(function ($anime) { + $jobs = $anime->importJobs; + return [ + 'anime_id' => $anime->id, + 'title' => $anime->title, + 'title_en' => $anime->title_en, + 'slug' => $anime->slug, + 'mal_id' => $anime->mal_id, + 'type' => $anime->type, + 'status' => $anime->status, + 'has_anizium' => $jobs->where('source', 'anizium')->isNotEmpty(), + 'has_animecix' => $jobs->where('source', 'animecix')->isNotEmpty(), + 'anizium_watch_ids' => $jobs->where('source', 'anizium')->pluck('watch_id')->filter()->unique()->values(), + 'animecix_title_ids' => $jobs->where('source', 'animecix')->pluck('animecix_title_id')->filter()->unique()->values(), + 'animecix_slugs' => $jobs->where('source', 'animecix')->pluck('animecix_slug')->filter()->unique()->values(), + ]; + }); + + return response()->json(['animes' => $animes, 'count' => $animes->count()]); + } +} diff --git a/app/Http/Controllers/Api/MessageApiController.php b/app/Http/Controllers/Api/MessageApiController.php new file mode 100644 index 0000000..c3772cb --- /dev/null +++ b/app/Http/Controllers/Api/MessageApiController.php @@ -0,0 +1,184 @@ +conversations() + ->with(['participants', 'lastMessage.user']) + ->orderByDesc('conversations.updated_at') + ->limit(50) + ->get() + ->map(function ($conv) use ($user) { + $other = $conv->participants->firstWhere('id', '!=', $user->id); + $last = $conv->lastMessage; + $unread = $conv->unreadCountFor($user->id); + + $preview = null; + if ($last) { + if (str_starts_with($last->body, 'IMAGE::')) $preview = '📷 Fotoğraf'; + elseif (str_starts_with($last->body, 'GIF::')) $preview = '🎞 GIF'; + elseif (str_starts_with($last->body, 'ANIMESHARE::')) { + try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime'); } catch (\Throwable) {} + } else { + $isMine = $last->user_id === $user->id; + $preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 60); + } + } + + return [ + 'id' => $conv->id, + 'other_user' => $other ? [ + 'id' => $other->id, + 'name' => $other->name, + 'username' => $other->username, + 'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null, + ] : null, + 'last_message' => $last ? [ + 'body' => $preview ?? '', + 'user_id' => $last->user_id, + 'created_at' => $last->created_at?->toISOString(), + ] : null, + 'unread_count' => $unread, + 'updated_at' => $conv->updated_at?->toISOString(), + ]; + }); + + return response()->json(['conversations' => $convs]); + } + + // GET /api/messages/{conversation} — messages in a conversation + public function show(Conversation $conversation) + { + $user = Auth::user(); + + abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403); + + $other = $conversation->participants()->where('user_id', '!=', $user->id)->first(); + + $messages = $conversation->messages() + ->with('user') + ->orderBy('created_at') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'user_id' => $m->user_id, + 'body' => $m->body, + 'created_at' => $m->created_at?->toISOString(), + 'author' => [ + 'id' => $m->user?->id, + 'name' => $m->user?->name, + 'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null, + ], + ]); + + // Mark as read + $conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json([ + 'messages' => $messages, + 'other_user' => $other ? [ + 'id' => $other->id, + 'name' => $other->name, + 'username' => $other->username, + 'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null, + ] : null, + ]); + } + + // POST /api/messages/{conversation} — send a message + public function send(Request $request, Conversation $conversation) + { + $user = Auth::user(); + + abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403); + + $request->validate(['body' => 'required|string|max:5000']); + + $message = Message::create([ + 'conversation_id' => $conversation->id, + 'user_id' => $user->id, + 'body' => $request->body, + ]); + + $conversation->touch(); + $conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json([ + 'id' => $message->id, + 'user_id' => $user->id, + 'body' => $message->body, + 'created_at' => $message->created_at->toISOString(), + 'author' => [ + 'id' => $user->id, + 'name' => $user->name, + 'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null, + ], + ]); + } + + // POST /api/messages/start/{user} — start or open conversation + public function startConversation(User $user) + { + $me = Auth::user(); + + if ($me->id === $user->id) abort(422, 'Kendinize mesaj gönderemezsiniz.'); + + $conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id)) + ->whereHas('participants', fn($q) => $q->where('user_id', $user->id)) + ->first(); + + if (!$conv) { + $conv = DB::transaction(function () use ($me, $user) { + $c = Conversation::create(); + $c->participants()->attach([$me->id, $user->id]); + return $c; + }); + } + + return response()->json(['conversation_id' => $conv->id]); + } + + // GET /api/messages/{conv}/poll?after={id} — poll for new messages (mobile) + public function poll(Request $request, Conversation $conversation) + { + $user = Auth::user(); + abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403); + + $after = (int) $request->query('after', 0); + + $messages = $conversation->messages() + ->with('user') + ->where('id', '>', $after) + ->orderBy('created_at') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'user_id' => $m->user_id, + 'body' => $m->body, + 'created_at' => $m->created_at?->toISOString(), + 'author' => [ + 'id' => $m->user?->id, + 'name' => $m->user?->name, + 'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null, + ], + ]); + + $conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json(['messages' => $messages]); + } +} diff --git a/app/Http/Controllers/Api/PlanApiController.php b/app/Http/Controllers/Api/PlanApiController.php new file mode 100644 index 0000000..e77cff8 --- /dev/null +++ b/app/Http/Controllers/Api/PlanApiController.php @@ -0,0 +1,66 @@ +where('is_public', true) + ->orderBy('sort_order') + ->orderBy('price') + ->get() + ->map(fn($p) => $this->fmtPlan($p)); + + $subscription = null; + $user = $request->user(); + if ($user) { + $sub = Subscription::where('user_id', $user->id) + ->where('status', 'active') + ->where('expires_at', '>', now()) + ->with('plan') + ->latest() + ->first(); + + if ($sub) { + $subscription = [ + 'plan_id' => $sub->plan_id, + 'plan_name' => $sub->plan?->name, + 'plan_slug' => $sub->plan?->slug, + 'status' => $sub->status, + 'expires_at' => $sub->expires_at?->toISOString(), + ]; + } + } + + return response()->json([ + 'plans' => $plans, + 'subscription' => $subscription, + 'is_premium' => $user?->isPremium() ?? false, + ]); + } + + private function fmtPlan(MembershipPlan $p): array + { + return [ + 'id' => $p->id, + 'name' => $p->name, + 'slug' => $p->slug, + 'description' => $p->description, + 'price' => $p->price, + 'purchase_link' => $p->purchase_link, + 'duration_days' => $p->duration_days, + 'trial_days' => $p->trial_days, + 'features' => $p->features ?? [], + 'perks' => $p->perks ?? [], + 'badge_label' => $p->badge_label, + 'accent_color' => $p->accent_color, + ]; + } +} diff --git a/app/Http/Controllers/Api/SocialApiController.php b/app/Http/Controllers/Api/SocialApiController.php new file mode 100644 index 0000000..403c87c --- /dev/null +++ b/app/Http/Controllers/Api/SocialApiController.php @@ -0,0 +1,462 @@ +where('episode_id', $episode->id) + ->where('is_hidden', false) + ->orderBy('timestamp_sec') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'user_id' => $c->user_id, + 'timestamp_sec' => $c->timestamp_sec, + 'body' => $c->body, + 'color' => $c->color, + 'username' => $c->user?->username ?? 'misafir', + ]); + + return response()->json(['comments' => $comments]); + } + + public function timestampCommentStore(Request $request, Episode $episode) + { + $data = $request->validate([ + 'timestamp_sec' => 'required|integer|min:0|max:86400', + 'body' => 'required|string|max:100', + 'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', + ]); + + $me = Auth::user(); + + $recent = EpisodeTimestampComment::where('user_id', $me->id) + ->where('episode_id', $episode->id) + ->where('created_at', '>=', now()->subSeconds(5)) + ->count(); + + if ($recent >= 2) { + return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429); + } + + $comment = EpisodeTimestampComment::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'timestamp_sec' => $data['timestamp_sec'], + 'body' => $data['body'], + 'color' => $data['color'] ?? '#ffffff', + ]); + + return response()->json(['ok' => true, 'id' => $comment->id]); + } + + // ── Tahmin Oyunu ───────────────────────────────────────────────────────── + + public function predictions(Episode $episode) + { + $me = Auth::id(); + + $predictions = EpisodePrediction::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('vote_count') + ->get() + ->map(fn($p) => [ + 'id' => $p->id, + 'body' => $p->body, + 'is_correct' => $p->is_correct, + 'vote_count' => $p->vote_count, + 'username' => $p->user?->username, + 'is_mine' => $me && $p->user_id === $me, + 'voted' => $me + ? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists() + : false, + 'created_at' => $p->created_at->diffForHumans(), + ]); + + $myPrediction = $me + ? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id + : null; + + return response()->json([ + 'predictions' => $predictions, + 'my_prediction' => $myPrediction, + ]); + } + + public function predictionStore(Request $request, Episode $episode) + { + $me = Auth::user(); + $data = $request->validate(['body' => 'required|string|min:5|max:280']); + + $existing = EpisodePrediction::where('episode_id', $episode->id) + ->where('user_id', $me->id)->first(); + + if ($existing) { + return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422); + } + + $prediction = EpisodePrediction::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $prediction->id]); + } + + public function predictionVote(EpisodePrediction $prediction) + { + $me = Auth::user(); + $existing = PredictionVote::where('prediction_id', $prediction->id)->where('user_id', $me->id)->first(); + + if ($existing) { + $existing->delete(); + $prediction->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]); + } + + PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]); + $prediction->increment('vote_count'); + return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]); + } + + // ── Watch Party ────────────────────────────────────────────────────────── + + public function partyCreate(Request $request) + { + $me = Auth::user(); + $data = $request->validate([ + 'episode_id' => 'required|exists:episodes,id', + 'is_private' => 'boolean', + 'password' => 'nullable|string|max:30', + 'max_members' => 'nullable|integer|min:2|max:20', + ]); + + WatchParty::where('host_user_id', $me->id)->delete(); + + $party = WatchParty::create([ + 'room_code' => WatchParty::generateCode(), + 'host_user_id' => $me->id, + 'episode_id' => $data['episode_id'], + 'is_private' => $data['is_private'] ?? false, + 'password' => isset($data['password']) ? Hash::make($data['password']) : null, + 'max_members' => $data['max_members'] ?? 10, + ]); + + WatchPartyMember::create(['party_id' => $party->id, 'user_id' => $me->id]); + + return response()->json([ + 'ok' => true, + 'room_code' => $party->room_code, + 'party' => $this->partyData($party), + ]); + } + + public function partyJoin(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + if ($party->is_private && $party->password) { + if (!Hash::check($request->input('password', ''), $party->password)) { + return response()->json(['error' => 'Yanlış şifre.'], 403); + } + } + + if ($party->activeMembers()->count() >= $party->max_members) { + return response()->json(['error' => 'Oda dolu.'], 403); + } + + WatchPartyMember::updateOrCreate( + ['party_id' => $party->id, 'user_id' => $me->id], + ['last_ping' => now()] + ); + + return response()->json([ + 'ok' => true, + 'party' => $this->partyData($party), + ]); + } + + public function partySync(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + if ($party->host_user_id === $me->id) { + $data = $request->validate([ + 'current_sec' => 'required|integer|min:0', + 'is_playing' => 'required|boolean', + ]); + $party->update([ + 'current_sec' => $data['current_sec'], + 'is_playing' => $data['is_playing'], + ]); + } + + WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id) + ->update(['last_ping' => now()]); + + $fresh = $party->fresh(); + return response()->json([ + 'current_sec' => $fresh->current_sec, + 'is_playing' => $fresh->is_playing, + 'members' => $this->memberList($party), + ]); + } + + public function partyLeave(string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete(); + + if ($party->host_user_id === $me->id) { + $party->delete(); + return response()->json(['ok' => true, 'dissolved' => true]); + } + + return response()->json(['ok' => true, 'dissolved' => false]); + } + + public function partyInfo(string $roomCode) + { + $party = WatchParty::with(['episode.anime', 'episode.season']) + ->where('room_code', $roomCode)->firstOrFail(); + return response()->json(['party' => $this->partyData($party)]); + } + + private function partyData(WatchParty $party): array + { + $party->loadMissing(['episode.anime', 'episode.season']); + return [ + 'room_code' => $party->room_code, + 'host_id' => $party->host_user_id, + 'episode_id' => $party->episode_id, + 'current_sec' => $party->current_sec, + 'is_playing' => $party->is_playing, + 'is_private' => $party->is_private, + 'max_members' => $party->max_members, + 'members' => $this->memberList($party), + 'anime_title' => $party->episode?->anime?->title, + 'anime_slug' => $party->episode?->anime?->slug, + 'episode_num' => $party->episode?->episode_number, + 'season_num' => $party->episode?->season?->season_number ?? 1, + ]; + } + + private function memberList(WatchParty $party): array + { + return $party->activeMembers()->with('user:id,name,username')->get() + ->map(fn($m) => [ + 'id' => $m->user_id, + 'name' => $m->user?->name, + 'username'=> $m->user?->username, + 'is_host' => $m->user_id === $party->host_user_id, + ])->toArray(); + } + + // ── Spoiler Kutular ────────────────────────────────────────────────────── + + public function spoilerBoxes(Episode $episode) + { + $me = Auth::id(); + $boxes = SpoilerBox::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('likes') + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => [ + 'id' => $b->id, + 'body' => $b->body, + 'is_spoiler' => $b->is_spoiler, + 'spoiler_score' => $b->spoiler_score, + 'likes' => $b->likes, + 'username' => $b->user?->username, + 'is_mine' => $me && $b->user_id === $me, + 'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false, + 'created_at' => $b->created_at->diffForHumans(), + ]); + + return response()->json(['boxes' => $boxes]); + } + + public function spoilerBoxStore(Request $request, Episode $episode) + { + $me = Auth::user(); + $data = $request->validate(['body' => 'required|string|min:3|max:600']); + + $isSpoiler = false; + $spoilerScore = 0; + $ai = new DeepSeekService(); + if ($ai->isConfigured()) { + try { + $raw = $ai->checkSpoiler($data['body']); + if ($raw) { + $isSpoiler = $raw['is_spoiler'] ?? false; + $spoilerScore = $raw['score'] ?? 0; + } + } catch (\Throwable $e) {} + } + + $box = SpoilerBox::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + 'is_spoiler' => $isSpoiler, + 'spoiler_score' => $spoilerScore, + ]); + + return response()->json(['ok' => true, 'id' => $box->id, 'is_spoiler' => $isSpoiler]); + } + + public function spoilerBoxLike(SpoilerBox $box) + { + $me = Auth::id(); + $existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first(); + + if ($existing) { + $existing->delete(); + $box->decrement('likes'); + return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]); + } + + SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]); + $box->increment('likes'); + return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]); + } + + // ── Zaman Kapsülü ──────────────────────────────────────────────────────── + + public function capsuleIndex() + { + $capsules = TimeCapsule::with('anime:id,title,slug,cover_image') + ->where('user_id', Auth::id()) + ->orderBy('unlock_at') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'anime_title' => $c->anime?->title, + 'anime_slug' => $c->anime?->slug, + 'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null, + 'unlock_at' => $c->unlock_at->toIso8601String(), + 'unlocked' => $c->isUnlocked(), + 'opened' => $c->isOpened(), + 'message' => ($c->isOpened() || $c->isUnlocked()) ? $c->message : null, + 'created_at' => $c->created_at->toIso8601String(), + ]); + + return response()->json(['capsules' => $capsules]); + } + + public function capsuleStore(Request $request) + { + $me = Auth::user(); + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'message' => 'required|string|min:5|max:1000', + 'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(), + ]); + + $data['user_id'] = $me->id; + $capsule = TimeCapsule::create($data); + return response()->json(['ok' => true, 'id' => $capsule->id]); + } + + public function capsuleOpen(TimeCapsule $capsule) + { + if ($capsule->user_id !== Auth::id()) { + return response()->json(['error' => 'Yetkisiz.'], 403); + } + if (!$capsule->isUnlocked()) { + return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422); + } + + $capsule->update(['opened_at' => now()]); + return response()->json(['ok' => true, 'message' => $capsule->message]); + } + + // ── Ruh Hali Motoru ────────────────────────────────────────────────────── + + private static array $moodGenres = [ + 'sad' => ['Drama', 'Romantizm'], + 'funny' => ['Komedi', 'Slice of Life'], + 'hype' => ['Aksiyon', 'Shounen', 'Spor'], + 'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'], + 'romance' => ['Romantizm', 'Shoujo'], + 'scary' => ['Korku', 'Supernatural', 'Gerilim'], + ]; + + public function moodRecommend(Request $request) + { + $mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood']; + $genres = self::$moodGenres[$mood] ?? []; + + $animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres)) + ->where('is_published', true) + ->inRandomOrder() + ->limit(6) + ->get(['id', 'title', 'cover_image', 'slug', 'rating']); + + return response()->json([ + 'animes' => $animes->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'cover' => $a->cover_image ? MediaUrl::fromStoragePath($a->cover_image) : null, + 'rating' => $a->rating, + ]), + ]); + } + + // ── Kullanıcı Takip ────────────────────────────────────────────────────── + + public function followToggle(User $user) + { + $me = Auth::user(); + if ($me->id === $user->id) { + return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422); + } + + $existing = \App\Models\UserFollow::where('follower_id', $me->id) + ->where('following_id', $user->id)->first(); + + if ($existing) { + $existing->delete(); + $following = false; + } else { + \App\Models\UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]); + $following = true; + } + + return response()->json([ + 'following' => $following, + 'followers_count' => \App\Models\UserFollow::where('following_id', $user->id)->count(), + ]); + } +} diff --git a/app/Http/Controllers/Api/TribunalApiController.php b/app/Http/Controllers/Api/TribunalApiController.php new file mode 100644 index 0000000..9c3b03d --- /dev/null +++ b/app/Http/Controllers/Api/TribunalApiController.php @@ -0,0 +1,205 @@ +withCount('votes'); + + if ($request->filled('anime_id')) { + $query->where('anime_id', $request->anime_id); + } + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $tribunals = $query->latest()->paginate(15); + + return response()->json([ + 'data' => collect($tribunals->items())->map(fn($t) => $this->formatTribunal($t))->values(), + 'has_more' => $tribunals->hasMorePages(), + 'next_page' => $tribunals->hasMorePages() ? $tribunals->currentPage() + 1 : null, + ]); + } + + public function show(Tribunal $tribunal) + { + $tribunal->load(['anime:id,title,slug,cover_image', 'creator:id,name,username']); + $me = Auth::id(); + $sides = $tribunal->allSides(); + + $vcounts = []; + foreach (array_keys($sides) as $key) { + $vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + } + + $myVote = $me ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') : null; + $myArg = $me ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() : null; + + $arguments = TribunalArgument::with('user:id,name,username') + ->where('tribunal_id', $tribunal->id) + ->orderByDesc('vote_count') + ->get() + ->map(fn($a) => [ + 'id' => $a->id, + 'side' => $a->side, + 'body' => $a->body, + 'vote_count' => $a->vote_count, + 'username' => $a->user?->username, + 'is_mine' => $me && $a->user_id === $me, + 'voted' => $me ? TribunalArgumentVote::where('argument_id', $a->id)->where('user_id', $me)->exists() : false, + ]); + + return response()->json([ + 'tribunal' => $this->formatTribunal($tribunal), + 'sides' => $sides, + 'vote_counts' => $vcounts, + 'total_votes' => array_sum($vcounts), + 'my_vote' => $myVote, + 'my_argument' => $myArg ? ['id' => $myArg->id, 'side' => $myArg->side, 'body' => $myArg->body] : null, + 'arguments' => $arguments, + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'question' => 'required|string|min:10|max:280', + 'side_a' => 'required|string|min:2|max:100', + 'side_b' => 'required|string|min:2|max:100', + 'extra_sides' => 'nullable|array|max:4', + 'extra_sides.*'=> 'required|string|min:2|max:100', + 'closes_at' => 'nullable|date|after:today', + ]); + + $me = Auth::user(); + + $tribunal = Tribunal::create([ + 'anime_id' => $data['anime_id'], + 'created_by' => $me->id, + 'question' => $data['question'], + 'side_a' => $data['side_a'], + 'side_b' => $data['side_b'], + 'extra_sides' => $data['extra_sides'] ?? [], + 'status' => 'open', + 'closes_at' => $data['closes_at'] ?? now()->addDays(7), + ]); + + return response()->json(['ok' => true, 'id' => $tribunal->id]); + } + + public function vote(Request $request, Tribunal $tribunal) + { + if ($tribunal->status !== 'open') { + return response()->json(['error' => 'Bu dava kapalı.'], 422); + } + + $sides = array_keys($tribunal->allSides()); + $data = $request->validate(['side' => 'required|in:' . implode(',', $sides)]); + $me = Auth::id(); + + $existing = TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first(); + + if ($existing) { + if ($existing->side === $data['side']) { + $existing->delete(); + $voted = null; + } else { + $existing->update(['side' => $data['side']]); + $voted = $data['side']; + } + } else { + TribunalVote::create(['tribunal_id' => $tribunal->id, 'user_id' => $me, 'side' => $data['side']]); + $voted = $data['side']; + } + + $vcounts = []; + foreach (array_keys($tribunal->allSides()) as $key) { + $vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + } + + return response()->json(['voted' => $voted, 'vote_counts' => $vcounts, 'total_votes' => array_sum($vcounts)]); + } + + public function argue(Request $request, Tribunal $tribunal) + { + if ($tribunal->status !== 'open') { + return response()->json(['error' => 'Bu dava kapalı.'], 422); + } + + $sides = array_keys($tribunal->allSides()); + $data = $request->validate([ + 'side' => 'required|in:' . implode(',', $sides), + 'body' => 'required|string|min:5|max:500', + ]); + + $me = Auth::user(); + $existing = TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me->id)->first(); + + if ($existing) { + $existing->update(['side' => $data['side'], 'body' => $data['body']]); + return response()->json(['ok' => true, 'id' => $existing->id, 'updated' => true]); + } + + $arg = TribunalArgument::create([ + 'tribunal_id' => $tribunal->id, + 'user_id' => $me->id, + 'side' => $data['side'], + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $arg->id, 'updated' => false]); + } + + public function argVote(TribunalArgument $argument) + { + $me = Auth::id(); + $existing = TribunalArgumentVote::where('argument_id', $argument->id)->where('user_id', $me)->first(); + + if ($existing) { + $existing->delete(); + $argument->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]); + } + + TribunalArgumentVote::create(['argument_id' => $argument->id, 'user_id' => $me]); + $argument->increment('vote_count'); + return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]); + } + + private function formatTribunal(Tribunal $t): array + { + return [ + 'id' => $t->id, + 'question' => $t->question, + 'status' => $t->status, + 'sides' => $t->allSides(), + 'votes_count' => $t->votes_count ?? TribunalVote::where('tribunal_id', $t->id)->count(), + 'closes_at' => $t->closes_at?->toIso8601String(), + 'created_at' => $t->created_at->diffForHumans(), + 'anime' => $t->anime ? [ + 'id' => $t->anime->id, + 'title' => $t->anime->title, + 'slug' => $t->anime->slug, + 'cover' => $t->anime->cover_image ? MediaUrl::fromStoragePath($t->anime->cover_image) : null, + ] : null, + 'creator' => $t->creator ? [ + 'name' => $t->creator->name, + 'username' => $t->creator->username, + ] : null, + ]; + } +} diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php new file mode 100644 index 0000000..02c2edb --- /dev/null +++ b/app/Http/Controllers/Api/UserApiController.php @@ -0,0 +1,300 @@ +input('status'); // watching|completed|plan_to_watch|dropped + + $query = Watchlist::where('user_id', $request->user()->id) + ->with('anime:id,title,slug,cover_image,rating,episode_count,status,release_year'); + + if ($status) $query->where('status', $status); + + $items = $query->orderByDesc('updated_at')->paginate(24); + + return response()->json([ + 'data' => collect($items->items())->map(fn($w) => [ + 'id' => $w->id, + 'status' => $w->status, + 'anime' => $w->anime ? [ + 'id' => $w->anime->id, + 'title' => $w->anime->title, + 'slug' => $w->anime->slug, + 'cover_url' => \App\Support\MediaUrl::fromStoragePath($w->anime->cover_image), + 'rating' => $w->anime->rating, + 'episode_count' => $w->anime->episode_count, + 'status' => $w->anime->status, + 'release_year' => $w->anime->release_year, + ] : null, + ]), + 'total' => $items->total(), + 'last_page'=> $items->lastPage(), + ]); + } + + public function watchlistToggle(Request $request, Anime $anime) + { + $user = $request->user(); + $status = $request->input('status', 'plan_to_watch'); + + $existing = Watchlist::where('user_id', $user->id)->where('anime_id', $anime->id)->first(); + + if ($existing) { + if ($existing->status === $status) { + $existing->delete(); + return response()->json(['in_watchlist' => false, 'status' => null]); + } + $existing->update(['status' => $status]); + return response()->json(['in_watchlist' => true, 'status' => $status]); + } + + Watchlist::create(['user_id' => $user->id, 'anime_id' => $anime->id, 'status' => $status]); + return response()->json(['in_watchlist' => true, 'status' => $status]); + } + + // ── Continue Watching ────────────────────────────────────────────────────── + + public function continueWatchingUpdate(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season_number' => 'required|integer|min:1', + 'episode_number' => 'required|integer|min:1', + 'percent_complete' => 'required|numeric|min:0|max:100', + ]); + + ContinueWatching::updateOrCreate( + ['user_id' => $request->user()->id, 'anime_id' => $data['anime_id']], + [ + 'season_number' => $data['season_number'], + 'episode_number' => $data['episode_number'], + 'percent_complete' => $data['percent_complete'], + ] + ); + + return response()->json(['ok' => true]); + } + + // ── Anime Rate ───────────────────────────────────────────────────────────── + + public function animeRate(Request $request, Anime $anime) + { + $data = $request->validate(['rating' => 'required|numeric|min:1|max:10']); + + AnimeRating::updateOrCreate( + ['user_id' => $request->user()->id, 'anime_id' => $anime->id], + ['rating' => $data['rating']] + ); + + $avg = AnimeRating::where('anime_id', $anime->id)->avg('rating'); + $anime->update(['rating' => round($avg, 1)]); + + return response()->json(['rating' => $data['rating'], 'avg' => round($avg, 1)]); + } + + // ── Follow ───────────────────────────────────────────────────────────────── + + public function followToggle(Request $request, Anime $anime) + { + $user = $request->user(); + $existing = AnimeFollow::where('user_id', $user->id)->where('anime_id', $anime->id)->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['following' => false]); + } + + AnimeFollow::create(['user_id' => $user->id, 'anime_id' => $anime->id]); + return response()->json(['following' => true]); + } + + // ── Notifications ────────────────────────────────────────────────────────── + + public function notifications(Request $request) + { + $items = UserNotification::where('user_id', $request->user()->id) + ->orderByDesc('created_at')->paginate(20); + + // Mark all as read + UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]); + + return response()->json([ + 'data' => collect($items->items())->map(fn($n) => [ + 'id' => $n->id, + 'type' => $n->type, + 'data' => $n->data ?? [], + 'is_read' => $n->is_read, + 'created_at' => $n->created_at?->diffForHumans(), + ]), + 'total' => $items->total(), + 'last_page'=> $items->lastPage(), + ]); + } + + public function notificationsCount(Request $request) + { + if (!$request->user()) return response()->json(['count' => 0]); + $count = UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->count(); + return response()->json(['count' => $count]); + } + + // ── Achievements ─────────────────────────────────────────────────────────── + + public function achievements(Request $request) + { + $all = Achievement::orderBy('points')->get(); + $earned = UserAchievement::where('user_id', $request->user()->id)->pluck('achievement_id')->toArray(); + $total = UserAchievement::where('user_id', $request->user()->id)->join('achievements','achievements.id','=','user_achievements.achievement_id')->sum('achievements.points'); + + return response()->json([ + 'total_points' => (int)$total, + 'data' => $all->map(fn($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'description' => $a->description, + 'icon' => $a->icon, + 'points' => $a->points, + 'earned' => in_array($a->id, $earned), + 'earned_at' => in_array($a->id, $earned) + ? UserAchievement::where('user_id', $request->user()->id)->where('achievement_id', $a->id)->value('created_at')?->toISOString() + : null, + ]), + ]); + } + + // ── Episode Notes ────────────────────────────────────────────────────────── + + public function noteStore(Request $request, $episodeId) + { + $data = $request->validate(['note' => 'required|string|max:1000']); + + $note = EpisodeNote::create([ + 'user_id' => $request->user()->id, + 'episode_id' => $episodeId, + 'note' => $data['note'], + ]); + + return response()->json(['id' => $note->id, 'note' => $note->note, 'created_at' => $note->created_at?->toISOString()], 201); + } + + public function noteDelete(Request $request, $noteId) + { + $note = EpisodeNote::where('id', $noteId)->where('user_id', $request->user()->id)->firstOrFail(); + $note->delete(); + return response()->json(['ok' => true]); + } + + public function episodeNotesList(Request $request, $episodeId) + { + $notes = EpisodeNote::where('user_id', $request->user()->id) + ->where('episode_id', $episodeId) + ->orderByDesc('created_at')->get(); + + return response()->json($notes->map(fn($n) => [ + 'id' => $n->id, + 'note' => $n->note, + 'created_at' => $n->created_at?->toISOString(), + ])); + } + + // ── Anime Requests ───────────────────────────────────────────────────────── + + public function requestIndex(Request $request) + { + $items = AnimeRequest::withCount('votes') + ->orderByDesc('votes_count')->orderByDesc('created_at')->paginate(20); + + return response()->json([ + 'data' => collect($items->items())->map(fn($r) => [ + 'id' => $r->id, + 'title' => $r->title, + 'note' => $r->note, + 'status' => $r->status, + 'votes_count' => $r->votes_count, + 'created_at' => $r->created_at?->diffForHumans(), + 'user_voted' => $request->user() + ? AnimeRequestVote::where('user_id', $request->user()->id)->where('anime_request_id', $r->id)->exists() + : false, + ]), + 'total' => $items->total(), + 'last_page'=> $items->lastPage(), + ]); + } + + public function requestStore(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:200', + 'note' => 'nullable|string|max:500', + ]); + + $req = AnimeRequest::create([ + 'user_id' => $request->user()->id, + 'title' => $data['title'], + 'note' => $data['note'] ?? null, + 'status' => 'pending', + ]); + + return response()->json(['id' => $req->id, 'title' => $req->title], 201); + } + + public function requestVote(Request $request, AnimeRequest $animeRequest) + { + $user = $request->user(); + $existing = AnimeRequestVote::where('user_id', $user->id)->where('anime_request_id', $animeRequest->id)->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['voted' => false, 'votes' => $animeRequest->votes()->count()]); + } + + AnimeRequestVote::create(['user_id' => $user->id, 'anime_request_id' => $animeRequest->id]); + return response()->json(['voted' => true, 'votes' => $animeRequest->votes()->count()]); + } + + // ── Profile Stats ────────────────────────────────────────────────────────── + + public function profileStats(Request $request) + { + $userId = $request->user()->id; + + $watchlistCount = Watchlist::where('user_id', $userId)->count(); + $completedCount = Watchlist::where('user_id', $userId)->where('status', 'completed')->count(); + $notifCount = UserNotification::where('user_id', $userId)->where('is_read', false)->count(); + $achPoints = UserAchievement::where('user_id', $userId) + ->join('achievements','achievements.id','=','user_achievements.achievement_id') + ->sum('achievements.points'); + $achCount = UserAchievement::where('user_id', $userId)->count(); + $commentCount = \App\Models\Comment::where('user_id', $userId)->count(); + + return response()->json([ + 'watchlist_count' => $watchlistCount, + 'completed_count' => $completedCount, + 'notif_count' => (int)$notifCount, + 'achievement_points'=> (int)$achPoints, + 'achievement_count' => $achCount, + 'comment_count' => $commentCount, + ]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +validate([ + 'code' => 'required|string|max:32', + ], [ + 'code.required' => 'Aktivasyon kodu boş bırakılamaz.', + ]); + + $rawCode = strtoupper(preg_replace('/[^A-Z0-9\-]/', '', trim($request->code))); + + $code = ActivationCode::with('plan') + ->where('code', $rawCode) + ->first(); + + if (! $code) { + return back()->withInput()->withErrors(['code' => 'Geçersiz aktivasyon kodu. Kodu kontrol edip tekrar deneyin.']); + } + + if ($code->isUsed()) { + return back()->withInput()->withErrors(['code' => 'Bu aktivasyon kodu daha önce kullanılmış.']); + } + + if ($code->isExpired()) { + return back()->withInput()->withErrors(['code' => 'Bu aktivasyon kodunun süresi dolmuş.']); + } + + $user = auth()->user(); + $plan = $code->plan; + + // Mevcut premium bitiş tarihine ekle (stack), yoksa şimdiden başla + $baseDate = ($user->premium_expires_at && $user->premium_expires_at->isFuture()) + ? $user->premium_expires_at + : now(); + $newExpiry = $baseDate->addDays($plan->duration_days); + + DB::transaction(function () use ($code, $user, $plan, $newExpiry) { + $code->update([ + 'used_by' => $user->id, + 'used_at' => now(), + ]); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $newExpiry, + 'payment_method' => 'activation_code', + 'payment_ref' => $code->code, + ]); + + $user->update([ + 'membership' => 'premium', + 'premium_expires_at' => $newExpiry, + ]); + }); + + return redirect()->route('premium.plans')->with('activation_success', [ + 'plan' => $plan->name, + 'expires_at' => $newExpiry->format('d.m.Y'), + ]); + } +} diff --git a/app/Http/Controllers/Frontend/AiController.php b/app/Http/Controllers/Frontend/AiController.php new file mode 100644 index 0000000..7f716da --- /dev/null +++ b/app/Http/Controllers/Frontend/AiController.php @@ -0,0 +1,270 @@ +get(['id', 'name']); + return view('frontend.ai.index', compact('genres')); + } + + /** + * POST /ai/chat — sohbet turu. + * Body: { messages: [{role, content}, ...] } + */ + public function chat(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $messages = $request->input('messages', []); + if (empty($messages)) { + return response()->json(['error' => 'Mesaj boş.'], 422); + } + + // Validate structure + $messages = array_filter($messages, fn($m) => isset($m['role'], $m['content']) && in_array($m['role'], ['user', 'assistant'])); + $messages = array_values($messages); + + $context = $ai->getAnimeContext(); + + // Sayfa bağlamı — kullanıcı anime/player sayfasındaysa AI'ya söyle + $pageCtx = trim($request->input('page_context', '')); + if ($pageCtx) { + $context .= "\n\n== KULLANICI ŞU AN BU SAYFADA ==\n{$pageCtx}"; + } + + $rawReply = $ai->chat($messages, $context); + + if (!$rawReply) { + return response()->json(['error' => 'AI yanıt vermedi, tekrar dene.'], 500); + } + + // [SUGGEST:id1,id2,id3] satırını parse et + $animeCards = []; + $cleanReply = $rawReply; + if (preg_match('/\[SUGGEST:([\d,\s]+)\]\s*$/m', $rawReply, $m)) { + $cleanReply = trim(str_replace($m[0], '', $rawReply)); + $ids = array_filter(array_map('intval', explode(',', $m[1]))); + if ($ids) { + $animes = Anime::whereIn('id', $ids) + ->where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'cover_image', 'rating', 'type', 'episode_count']); + $animeMap = $animes->keyBy('id'); + foreach ($ids as $id) { + if ($a = $animeMap[$id] ?? null) { + $animeCards[] = [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'cover' => $a->cover_url, + 'rating' => $a->rating, + 'type' => $a->type, + 'episode_count' => $a->episode_count, + 'genres' => $a->genres->pluck('name')->take(3)->join(', '), + ]; + } + } + } + } + + // Log + $lastUser = collect($messages)->last(fn($m) => $m['role'] === 'user'); + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'chat','query_text'=>substr($lastUser['content']??'',0,500),'created_at'=>now()]); + + return response()->json(['reply' => $cleanReply, 'anime_cards' => $animeCards]); + } + + /** + * POST /ai/recommend — kişisel öneri. + * Body: { mood?, genres[]?, type? } + */ + public function recommend(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $mood = trim($request->input('mood', '')); + $genres = $request->input('genres', []); + $type = $request->input('type', ''); + + $prefs = []; + if ($mood) $prefs[] = "Ruh hali / tema: {$mood}"; + if ($genres) $prefs[] = 'Tercih edilen türler: ' . implode(', ', array_slice((array)$genres, 0, 6)); + if ($type) $prefs[] = 'İçerik tipi: ' . ($type === 'movie' ? 'Film' : 'Dizi'); + $prefStr = $prefs ? implode("\n", $prefs) : 'Genel tavsiye, en beğenilen animeler'; + + $animes = Anime::where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'type', 'status', 'rating', 'release_year', 'cover_image', 'episode_count']); + + $result = $ai->recommend($prefStr, $animes->toArray()); + + if (!$result) { + return response()->json(['error' => 'Öneri üretilemedi.'], 500); + } + + $animeMap = $animes->keyBy('id'); + $recs = array_values(array_filter(array_map(function ($item) use ($animeMap) { + $anime = $animeMap[$item['id'] ?? 0] ?? null; + if (!$anime) return null; + return [ + 'id' => $anime->id, + 'title' => $anime->title, + 'slug' => $anime->slug, + 'cover' => $anime->cover_url, + 'rating' => $anime->rating, + 'type' => $anime->type, + 'episode_count' => $anime->episode_count, + 'reason' => $item['reason'] ?? '', + ]; + }, $result))); + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'recommend','query_text'=>substr($prefStr,0,500),'created_at'=>now()]); + + return response()->json(['recommendations' => $recs]); + } + + /** + * POST /ai/search — doğal dil ile anime ara. + * Body: { query } + */ + public function search(Request $request) + { + $query = trim($request->input('query', '')); + if (!$query) { + return response()->json(['error' => 'Sorgu boş.'], 422); + } + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $animes = Anime::where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'type', 'rating', 'release_year', 'cover_image']); + + $ids = $ai->naturalSearch($query, $animes->toArray()); + if (!$ids) { + return response()->json(['results' => []]); + } + + $animeMap = $animes->keyBy('id'); + $results = array_values(array_filter(array_map(function ($id) use ($animeMap) { + $anime = $animeMap[(int)$id] ?? null; + if (!$anime) return null; + return [ + 'id' => $anime->id, + 'title' => $anime->title, + 'slug' => $anime->slug, + 'cover' => $anime->cover_url, + 'rating' => $anime->rating, + 'type' => $anime->type, + ]; + }, $ids))); + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'search','query_text'=>substr($query,0,500),'created_at'=>now()]); + + return response()->json(['results' => $results]); + } + + /** + * POST /ai/episode-info — bölüm hakkında AI analizi. + * Body: { anime_title, episode_number, episode_title?, description? } + */ + public function episodeInfo(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $animeTitle = trim($request->input('anime_title', '')); + $episodeNumber = (int) $request->input('episode_number', 1); + $episodeTitle = trim($request->input('episode_title', '')); + $description = trim($request->input('description', '')); + + if (!$animeTitle) { + return response()->json(['error' => 'Anime adı gerekli.'], 422); + } + + $info = $ai->episodeInfo($animeTitle, $episodeNumber, $episodeTitle, $description); + if (!$info) { + return response()->json(['error' => 'Analiz yapılamadı.'], 500); + } + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'episode_info','query_text'=>"{$animeTitle} E{$episodeNumber}",'created_at'=>now()]); + + return response()->json(['info' => $info]); + } + + /** + * POST /ai/similar — benzer animeler. + * Body: { anime_id } + */ + public function similar(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $anime = Anime::with('genres:id,name')->find($request->input('anime_id')); + if (!$anime) { + return response()->json(['error' => 'Anime bulunamadı.'], 404); + } + + $genres = $anime->genres->pluck('name')->join(', '); + $prefStr = "Şu anime ile benzer: {$anime->title}\n" + . "Türler: {$genres}\n" + . "Tip: " . ($anime->type === 'movie' ? 'Film' : 'Dizi') . "\n" + . "Bu animeyi beğenen izleyicilere benzer içerik öner. Aynı animeyi önerme!"; + + $animes = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'type', 'rating', 'release_year', 'cover_image']); + + $result = $ai->recommend($prefStr, $animes->toArray()); + if (!$result) { + return response()->json(['similar' => []]); + } + + $animeMap = $animes->keyBy('id'); + $similar = array_values(array_filter(array_map(function ($item) use ($animeMap) { + $a = $animeMap[$item['id'] ?? 0] ?? null; + if (!$a) return null; + return [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'cover' => $a->cover_url, + 'rating' => $a->rating, + 'type' => $a->type, + 'reason' => $item['reason'] ?? '', + ]; + }, $result))); + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'similar','query_text'=>$anime->title,'created_at'=>now()]); + + return response()->json(['similar' => $similar]); + } +} diff --git a/app/Http/Controllers/Frontend/AnimeController.php b/app/Http/Controllers/Frontend/AnimeController.php new file mode 100644 index 0000000..36bfceb --- /dev/null +++ b/app/Http/Controllers/Frontend/AnimeController.php @@ -0,0 +1,64 @@ +is_published, 404); + + $anime->load([ + 'genres', + 'seasons' => fn($q) => $q->orderBy('season_number'), + 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'), + ]); + + $related = Anime::whereHas('genres', fn($q) => + $q->whereIn('genres.id', $anime->genres->pluck('id')) + ) + ->where('id', '!=', $anime->id) + ->where('is_published', true) + ->take(10) + ->get(); + + // Auth kullanıcı verileri + $userWatchlist = null; + $userRating = null; + $continueEp = null; + $userFollowing = false; + + if (auth()->check()) { + $userWatchlist = Watchlist::where('user_id', auth()->id()) + ->where('anime_id', $anime->id)->first(); + $userRating = AnimeRating::where('user_id', auth()->id()) + ->where('anime_id', $anime->id)->value('rating'); + $continueEp = ContinueWatching::where('user_id', auth()->id()) + ->where('anime_id', $anime->id) + ->where('percent_complete', '<', 95) + ->first(); + $userFollowing = AnimeFollow::where('user_id', auth()->id()) + ->where('anime_id', $anime->id)->exists(); + } + + // Sosyal: Bu animeyi listeleyen son kullanıcılar + $watchers = Watchlist::where('anime_id', $anime->id) + ->when(auth()->id(), fn($q) => $q->where('user_id', '!=', auth()->id())) + ->with('user:id,name,username,avatar') + ->latest() + ->limit(8) + ->get() + ->map(fn($w) => $w->user) + ->filter(); + $watcherCount = Watchlist::where('anime_id', $anime->id)->count(); + + return view('frontend.anime', compact('anime', 'related', 'userWatchlist', 'userRating', 'continueEp', 'userFollowing', 'watchers', 'watcherCount')); + } +} diff --git a/app/Http/Controllers/Frontend/AuthController.php b/app/Http/Controllers/Frontend/AuthController.php new file mode 100644 index 0000000..be84b2b --- /dev/null +++ b/app/Http/Controllers/Frontend/AuthController.php @@ -0,0 +1,174 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required', + ], [ + 'email.required' => 'E-posta zorunludur.', + 'email.email' => 'Geçerli bir e-posta girin.', + 'password.required' => 'Şifre zorunludur.', + ]); + + $credentials = $request->only('email', 'password'); + $remember = $request->boolean('remember'); + + if (Auth::attempt($credentials, $remember)) { + $user = Auth::user(); + if ($user->is_banned) { + Auth::logout(); + return back()->withErrors(['email' => 'Hesabınız yasaklanmıştır: ' . ($user->ban_reason ?: 'İhlal.')]); + } + $request->session()->regenerate(); + \App\Support\ActivityLogger::log('login', $user->id, null, null, null, $request); + return redirect()->intended(route('home')); + } + + return back()->withErrors(['email' => 'E-posta veya şifre hatalı.'])->withInput($request->only('email')); + } + + public function showRegister() + { + return view('frontend.auth.register'); + } + + public function register(Request $request) + { + $request->validate([ + 'name' => 'required|string|min:2|max:60', + 'email' => 'required|email|unique:users,email', + 'password' => ['required', 'confirmed', Password::min(6)], + ], [ + 'name.required' => 'İsim zorunludur.', + 'name.min' => 'İsim en az 2 karakter olmalıdır.', + 'email.required' => 'E-posta zorunludur.', + 'email.unique' => 'Bu e-posta zaten kayıtlı.', + 'password.required' => 'Şifre zorunludur.', + 'password.confirmed' => 'Şifreler eşleşmiyor.', + 'password.min' => 'Şifre en az 6 karakter olmalıdır.', + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'role' => 'user', + 'membership' => 'free', + ]); + + Auth::login($user); + $request->session()->regenerate(); + \App\Support\ActivityLogger::log('register', $user->id, null, null, null, $request); + + // Doğrulama e-postası gönder (SMTP ayarlıysa) + try { + EmailVerificationController::sendVerificationMail($user); + } catch (\Throwable) {} + + return redirect(route('home')); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect(route('home')); + } + + // ── Social Auth ─────────────────────────────────────────────────────────── + + private const ALLOWED_PROVIDERS = ['google', 'discord']; + + public function socialRedirect(string $provider) + { + if (!in_array($provider, self::ALLOWED_PROVIDERS)) { + abort(404); + } + + return Socialite::driver($provider)->redirect(); + } + + public function socialCallback(string $provider, Request $request) + { + if (!in_array($provider, self::ALLOWED_PROVIDERS)) { + abort(404); + } + + try { + $socialUser = Socialite::driver($provider)->user(); + } catch (\Throwable $e) { + return redirect()->route('frontend.login') + ->withErrors(['email' => 'Sosyal giriş başarısız, lütfen tekrar deneyin.']); + } + + $email = $socialUser->getEmail(); + $name = $socialUser->getName() ?: $socialUser->getNickname() ?: 'Kullanıcı'; + $avatar = $socialUser->getAvatar(); + $socialId = $socialUser->getId(); + + // Aynı provider + social_id ile kayıtlı kullanıcı var mı? + $user = User::where('social_provider', $provider) + ->where('social_id', $socialId) + ->first(); + + if (!$user && $email) { + // Aynı e-posta ile kayıtlı normal hesap var mı? + $user = User::where('email', $email)->first(); + if ($user) { + // Mevcut hesaba sosyal giriş bilgisini bağla + $user->update([ + 'social_provider' => $provider, + 'social_id' => $socialId, + 'avatar' => $user->avatar ?: $avatar, + ]); + } + } + + if (!$user) { + // Yeni kullanıcı oluştur + $user = User::create([ + 'name' => $name, + 'email' => $email, + 'avatar' => $avatar, + 'social_provider' => $provider, + 'social_id' => $socialId, + 'password' => null, + 'role' => 'user', + 'membership' => 'free', + ]); + \App\Support\ActivityLogger::log('register', $user->id, null, null, null, $request); + } + + if ($user->is_banned) { + return redirect()->route('frontend.login') + ->withErrors(['email' => 'Hesabınız yasaklanmıştır: ' . ($user->ban_reason ?: 'İhlal.')]); + } + + Auth::login($user, true); + $request->session()->regenerate(); + \App\Support\ActivityLogger::log('login', $user->id, null, null, null, $request); + + return redirect()->intended(route('home')); + } +} diff --git a/app/Http/Controllers/Frontend/BlogController.php b/app/Http/Controllers/Frontend/BlogController.php new file mode 100644 index 0000000..d444c06 --- /dev/null +++ b/app/Http/Controllers/Frontend/BlogController.php @@ -0,0 +1,53 @@ +published() + ->orderByDesc('published_at') + ->paginate(12); + + $recent = BlogPost::published()->orderByDesc('published_at')->limit(5)->get(); + $popular = BlogPost::published()->orderByDesc('views')->limit(5)->get(); + + return view('frontend.blog.index', compact('posts', 'recent', 'popular')); + } + + public function show(string $slug) + { + $post = BlogPost::with('anime.genres') + ->where('slug', $slug) + ->where('status', 'published') + ->firstOrFail(); + + $post->increment('views'); + + // İlgili yazılar: aynı anime veya benzer anahtar kelimeler + $related = BlogPost::published() + ->where('id', '!=', $post->id) + ->when($post->anime_id, fn($q) => $q->where('anime_id', $post->anime_id) + ->orWhere('focus_keyword', 'like', '%' . explode(' ', $post->focus_keyword ?? '')[0] . '%') + ) + ->orderByDesc('published_at') + ->limit(4) + ->get(); + + // Linked anime'ler + $linkedAnimes = collect(); + if (!empty($post->linked_anime_ids)) { + $linkedAnimes = Anime::whereIn('id', $post->linked_anime_ids) + ->where('is_published', true) + ->get(); + } + + return view('frontend.blog.show', compact('post', 'related', 'linkedAnimes')); + } +} diff --git a/app/Http/Controllers/Frontend/CheckoutController.php b/app/Http/Controllers/Frontend/CheckoutController.php new file mode 100644 index 0000000..e648057 --- /dev/null +++ b/app/Http/Controllers/Frontend/CheckoutController.php @@ -0,0 +1,183 @@ +setApiKey(config('iyzico.api_key')); + $opt->setSecretKey(config('iyzico.secret_key')); + $opt->setBaseUrl(config('iyzico.base_url')); + return $opt; + } + + public function show(MembershipPlan $plan) + { + abort_if(!$plan->is_active || !$plan->is_public, 404); + return view('frontend.checkout.show', compact('plan')); + } + + public function initialize(Request $request, MembershipPlan $plan) + { + abort_if(!$plan->is_active || !$plan->is_public, 404); + + $v = $request->validate([ + 'full_name' => 'required|string|max:100', + 'phone' => 'required|string|max:20', + 'city' => 'required|string|max:80', + 'address' => 'required|string|max:300', + 'identity_no' => 'nullable|digits:11', + ]); + + $user = Auth::user(); + $conversationId = Str::uuid()->toString(); + $price = number_format($plan->price, 2, '.', ''); + + $parts = explode(' ', trim($v['full_name']), 2); + $firstName = $parts[0]; + $lastName = $parts[1] ?? '-'; + + $payment = Payment::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'conversation_id' => $conversationId, + 'amount' => $plan->price, + 'status' => 'pending', + ]); + + $req = new \Iyzipay\Request\CreateCheckoutFormInitializeRequest(); + $req->setLocale(\Iyzipay\Model\Locale::TR); + $req->setConversationId($conversationId); + $req->setPrice($price); + $req->setPaidPrice($price); + $req->setCurrency(\Iyzipay\Model\Currency::TL); + $req->setBasketId('payment-' . $payment->id); + $req->setPaymentGroup(\Iyzipay\Model\PaymentGroup::PRODUCT); + $req->setCallbackUrl(route('checkout.callback')); + $req->setEnabledInstallments([1, 2, 3, 6, 9, 12]); + + $buyer = new \Iyzipay\Model\Buyer(); + $buyer->setId('u' . $user->id); + $buyer->setName($firstName); + $buyer->setSurname($lastName); + $buyer->setGsmNumber('+9' . preg_replace('/\D/', '', $v['phone'])); + $buyer->setEmail($user->email); + $buyer->setIdentityNumber($v['identity_no'] ?: '11111111111'); + $buyer->setRegistrationAddress($v['address']); + $buyer->setIp($request->ip()); + $buyer->setCity($v['city']); + $buyer->setCountry('Turkey'); + $req->setBuyer($buyer); + + $addr = new \Iyzipay\Model\Address(); + $addr->setContactName($v['full_name']); + $addr->setCity($v['city']); + $addr->setCountry('Turkey'); + $addr->setAddress($v['address']); + $req->setBillingAddress($addr); + $req->setShippingAddress($addr); + + $item = new \Iyzipay\Model\BasketItem(); + $item->setId('plan' . $plan->id); + $item->setName($plan->name . ' Premium (' . $plan->duration_days . ' gün)'); + $item->setCategory1('Dijital Ürün'); + $item->setItemType(\Iyzipay\Model\BasketItemType::VIRTUAL); + $item->setPrice($price); + $req->setBasketItems([$item]); + + $form = \Iyzipay\Model\CheckoutFormInitialize::create($req, $this->options()); + + if ($form->getStatus() !== 'success') { + $payment->update(['status' => 'failed', 'error_message' => $form->getErrorMessage()]); + return back()->withErrors(['general' => 'Ödeme başlatılamadı: ' . $form->getErrorMessage()]); + } + + $payment->update(['token' => $form->getToken()]); + + return view('frontend.checkout.form', [ + 'plan' => $plan, + 'formContent' => $form->getCheckoutFormContent(), + ]); + } + + public function callback(Request $request) + { + $token = $request->input('token'); + + if (!$token) { + return redirect()->route('checkout.failed'); + } + + $payment = Payment::where('token', $token)->where('status', 'pending')->first(); + + if (!$payment) { + return redirect()->route('checkout.failed'); + } + + $req = new \Iyzipay\Request\RetrieveCheckoutFormRequest(); + $req->setLocale(\Iyzipay\Model\Locale::TR); + $req->setConversationId($payment->conversation_id); + $req->setToken($token); + + $result = \Iyzipay\Model\CheckoutForm::retrieve($req, $this->options()); + + if ($result->getStatus() === 'success' && $result->getPaymentStatus() === 'SUCCESS') { + $payment->update([ + 'status' => 'success', + 'iyzico_payment_id' => $result->getPaymentId(), + 'paid_at' => now(), + ]); + + $plan = $payment->plan; + $user = $payment->user; + $hasEver = Subscription::where('user_id', $user->id)->exists(); + $bonus = ($hasEver === false && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0; + $expiresAt = now()->addDays($plan->duration_days + $bonus); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $expiresAt, + 'payment_method' => 'iyzico', + 'payment_ref' => $result->getPaymentId(), + ]); + + $user->update([ + 'membership' => 'premium', + 'premium_expires_at' => $expiresAt, + ]); + + session(['checkout_plan_name' => $plan->name]); + return redirect()->route('checkout.success'); + } + + $payment->update([ + 'status' => 'failed', + 'error_message' => $result->getErrorMessage(), + ]); + + return redirect()->route('checkout.failed'); + } + + public function success() + { + return view('frontend.checkout.success'); + } + + public function failed() + { + return view('frontend.checkout.failed'); + } +} diff --git a/app/Http/Controllers/Frontend/CommentController.php b/app/Http/Controllers/Frontend/CommentController.php new file mode 100644 index 0000000..e2e0d65 --- /dev/null +++ b/app/Http/Controllers/Frontend/CommentController.php @@ -0,0 +1,287 @@ +hasPerk('extended_comments')) ? 1000 : 500; + + $request->validate([ + 'commentable_type' => 'required|in:episode,anime', + 'commentable_id' => 'required|integer', + 'content' => 'nullable|string|max:' . $maxLength, + 'gif_url' => 'nullable|url|max:500', + 'parent_id' => 'nullable|integer|exists:comments,id', + ]); + + if (empty(trim($request->content ?? '')) && empty($request->gif_url)) { + return response()->json(['error' => 'Yorum boş olamaz.'], 422); + } + + if (!empty($request->gif_url) && (!$user || !$user->hasPerk('comment_gif'))) { + return response()->json(['error' => 'GIF eklemek için premium üyelik gerekiyor.'], 403); + } + + $commentsEnabled = Setting::get('comments_enabled', '1') === '1'; + + if (!$commentsEnabled) { + return response()->json(['error' => 'Yorumlar şu an kapalı.'], 403); + } + + $content = trim($request->content ?? ''); + + // AI moderasyon (sadece metin içeren yorumlar için, GIF yorumları direkt onaylanır) + $aiService = new DeepSeekService(); + $pendingReason = null; + $status = 'approved'; + + if (!empty($content) && $aiService->isConfigured()) { + $mod = $aiService->moderateComment($content); + + if ($mod['is_rude']) { + $status = 'pending'; + $pendingReason = 'rude'; + } elseif ($mod['is_spoiler']) { + $status = 'pending'; + $pendingReason = 'spoiler'; + } + } elseif (Setting::get('comments_require_approval', '0') === '1') { + $status = 'pending'; + $pendingReason = 'manual'; + } + + $comment = Comment::create([ + 'user_id' => Auth::id(), + 'commentable_type' => $request->commentable_type, + 'commentable_id' => $request->commentable_id, + 'parent_id' => $request->parent_id ?: null, + 'content' => $content, + 'gif_url' => $request->gif_url ?: null, + 'status' => $status, + 'like_count' => 0, + ]); + + $comment->load('user'); + + if ($status !== 'approved') { + return response()->json([ + 'ok' => true, + 'pending' => true, + 'pending_reason' => $pendingReason, + ]); + } + + return response()->json([ + 'ok' => true, + 'pending' => false, + 'comment' => $this->formatComment($comment, Auth::id()), + ]); + } + + /** + * POST /comments/{comment}/like — beğen/beğenmekten vazgeç (toggle) + */ + public function like(Comment $comment) + { + $userId = Auth::id(); + + $existing = CommentLike::where('user_id', $userId) + ->where('comment_id', $comment->id) + ->first(); + + if ($existing) { + $existing->delete(); + $comment->decrement('like_count'); + $liked = false; + } else { + CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]); + $comment->increment('like_count'); + $liked = true; + } + + return response()->json([ + 'ok' => true, + 'liked' => $liked, + 'like_count' => $comment->fresh()->like_count, + ]); + } + + /** + * GET /comments/gif-search — GIF arama (Giphy öncelikli, Tenor fallback) + */ + public function gifSearch(Request $request) + { + $query = $request->query('q', 'anime reaction'); + $giphyKey = Setting::get('giphy_api_key', ''); + $tenorKey = Setting::get('tenor_api_key', ''); + + // Giphy + if (!empty($giphyKey)) { + return $this->searchGiphy($query, $giphyKey); + } + + // Tenor + if (!empty($tenorKey)) { + return $this->searchTenor($query, $tenorKey); + } + + return response()->json(['results' => [], 'error' => 'no_key']); + } + + private function searchGiphy(string $query, string $apiKey) + { + try { + $res = Http::timeout(8)->get('https://api.giphy.com/v1/gifs/search', [ + 'api_key' => $apiKey, + 'q' => $query, + 'limit' => 24, + 'rating' => 'pg-13', + 'lang' => 'en', + ]); + + if (!$res->successful()) { + \Log::warning('Giphy API failed', ['status' => $res->status()]); + return response()->json(['results' => [], 'error' => 'giphy_fail']); + } + + $gifs = collect($res->json('data', []))->map(function ($r) { + $images = $r['images'] ?? []; + $preview = $images['fixed_height_small']['url'] + ?? $images['fixed_height']['url'] + ?? $images['downsized']['url'] + ?? null; + $full = $images['downsized_medium']['url'] + ?? $images['fixed_height']['url'] + ?? $images['original']['url'] + ?? $preview; + if (!$preview || !$full) return null; + return [ + 'id' => $r['id'], + 'preview' => $preview, + 'url' => $full, + 'title' => $r['title'] ?? '', + ]; + })->filter()->values(); + + return response()->json(['results' => $gifs]); + } catch (\Exception $e) { + \Log::error('Giphy error: ' . $e->getMessage()); + return response()->json(['results' => [], 'error' => $e->getMessage()]); + } + } + + private function searchTenor(string $query, string $apiKey) + { + try { + $res = Http::timeout(8)->get('https://tenor.googleapis.com/v2/search', [ + 'q' => $query, + 'key' => $apiKey, + 'limit' => 24, + 'media_filter' => 'tinygif,gif', + 'contentfilter' => 'medium', + ]); + + if (!$res->successful()) { + return response()->json(['results' => [], 'error' => 'tenor_fail']); + } + + $gifs = collect($res->json('results', []))->map(function ($r) { + $formats = $r['media_formats'] ?? []; + $preview = $formats['tinygif']['url'] ?? $formats['mediumgif']['url'] ?? $formats['gif']['url'] ?? null; + $full = $formats['gif']['url'] ?? $formats['mediumgif']['url'] ?? $preview ?? null; + if (!$preview || !$full) return null; + return [ + 'id' => $r['id'], + 'preview' => $preview, + 'url' => $full, + 'title' => $r['content_description'] ?? '', + ]; + })->filter()->values(); + + return response()->json(['results' => $gifs]); + } catch (\Exception $e) { + return response()->json(['results' => [], 'error' => $e->getMessage()]); + } + } + + /** + * GET /comments — bölüm yorumlarını getir (AJAX sayfalama) + */ + public function index(Request $request) + { + $userId = Auth::id(); + + $query = Comment::where('commentable_type', $request->type) + ->where('commentable_id', $request->id) + ->whereNull('parent_id') + ->where('status', 'approved') + ->with(['user', 'replies' => fn($q) => $q->where('status', 'approved')->with('user')->orderBy('created_at')]) + ->orderByDesc('is_pinned') + ->orderByDesc('like_count') + ->orderByDesc('created_at'); + + $comments = $query->paginate(20); + + return response()->json([ + 'data' => $comments->map(fn($c) => $this->formatComment($c, $userId, true)), + 'has_more' => $comments->hasMorePages(), + 'next_page'=> $comments->currentPage() + 1, + ]); + } + + private function formatComment(Comment $c, ?int $userId, bool $withReplies = false): array + { + $data = [ + 'id' => $c->id, + 'content' => $c->content, + 'gif_url' => $c->gif_url, + 'like_count' => $c->like_count, + 'is_liked' => $userId ? $c->likes()->where('user_id', $userId)->exists() : false, + 'is_pinned' => $c->is_pinned, + 'parent_id' => $c->parent_id, + 'created_at' => $c->created_at?->diffForHumans(), + 'user' => $c->user ? [ + 'id' => $c->user->id, + 'name' => $c->user->name, + 'username' => $c->user->username, + 'avatar' => $c->user->gif_avatar && $c->user->hasPerk('gif_avatar') + ? $c->user->gif_avatar + : ($c->user->avatar ? \App\Support\MediaUrl::fromStoragePath($c->user->avatar) : null), + 'role' => $c->user->role, + 'is_following' => $userId && $userId !== $c->user->id + ? \App\Models\UserFollow::where('follower_id', $userId)->where('following_id', $c->user->id)->exists() + : false, + 'comment_bg' => $c->user->comment_bg, + 'comment_glow' => $c->user->comment_glow, + 'comment_signature' => $c->user->comment_signature, + 'username_color' => $c->user->username_color, + 'username_effect' => $c->user->username_effect, + 'profile_frame' => $c->user->profile_frame, + 'profile_badge' => $c->user->profile_badge, + 'admin_badge' => $c->user->admin_badge, + 'watch_rank' => $c->user->watchRank(), + ] : null, + ]; + + if ($withReplies && $c->relationLoaded('replies')) { + $data['replies'] = $c->replies->map(fn($r) => $this->formatComment($r, $userId))->toArray(); + } + + return $data; + } +} diff --git a/app/Http/Controllers/Frontend/DiscoverController.php b/app/Http/Controllers/Frontend/DiscoverController.php new file mode 100644 index 0000000..4c93463 --- /dev/null +++ b/app/Http/Controllers/Frontend/DiscoverController.php @@ -0,0 +1,253 @@ +orderBy('name')->get(['id', 'name', 'slug']); + return view('frontend.discover', compact('genres')); + } + + public function cards(Request $request) + { + $genreSlug = $request->input('genre', ''); + $type = $request->input('type', ''); + $limit = min((int) $request->input('limit', 10), 10); + + // Auth state'i al + $isAuth = auth()->check(); + $uid = $isAuth ? auth()->id() : null; + + try { + $idQuery = Anime::where('is_published', true)->select('id'); + + if ($genreSlug) { + $idQuery->whereHas('genres', fn($q) => $q->where('slug', $genreSlug)); + } + if ($type) { + $idQuery->where('type', $type); + } + + if ($isAuth && $uid) { + $exclude = AnimeSwipe::where('user_id', $uid)->pluck('anime_id') + ->merge(Watchlist::where('user_id', $uid)->pluck('anime_id')) + ->unique(); + if ($exclude->isNotEmpty()) { + $idQuery->whereNotIn('id', $exclude); + } + } + + $ids = $idQuery->pluck('id'); + if ($ids->isEmpty()) { + return response()->json(['cards' => [], 'has_more' => false]); + } + + $randomIds = $ids->shuffle()->take($limit); + + $animes = Anime::whereIn('id', $randomIds) + ->with('genres:id,name') + ->get() + ->shuffle(); + + $cards = $animes->map(function (Anime $anime) { + $hook = $anime->discovery_hook + ?: ($anime->description ? Str::limit(strip_tags($anime->description), 130) : null); + + return [ + 'id' => $anime->id, + 'slug' => $anime->slug, + 'title' => $anime->title, + 'cover_url' => $anime->coverUrl, + 'banner_url' => $anime->bannerUrl, + 'rating' => $anime->rating ? number_format($anime->rating, 1) : null, + 'year' => $anime->release_year, + 'type' => $anime->type, + 'status' => $anime->status, + 'episode_count' => $anime->episode_count, + 'genres' => $anime->genres->take(3)->pluck('name')->values(), + 'hook' => $hook, + 'description' => $anime->description ? Str::limit(strip_tags($anime->description), 420) : null, + ]; + }); + + return response()->json([ + 'cards' => $cards, + 'has_more' => $animes->count() === $limit, + ]); + + } catch (\Exception $e) { + \Log::error('Discover cards error: ' . $e->getMessage()); + return response()->json(['cards' => [], 'has_more' => false, 'error' => true]); + } + } + + public function swipe(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|integer|exists:animes,id', + 'direction' => 'required|in:like,skip', + ]); + + if (auth()->check()) { + $uid = auth()->id(); + AnimeSwipe::updateOrCreate( + ['user_id' => $uid, 'anime_id' => $data['anime_id']], + ['direction' => $data['direction']] + ); + + if ($data['direction'] === 'like') { + Watchlist::updateOrCreate( + ['user_id' => $uid, 'anime_id' => $data['anime_id']], + ['status' => 'plan'] + ); + } + } else { + $seen = session('guest_swipes', []); + $seen[] = $data['anime_id']; + session(['guest_swipes' => array_unique(array_slice($seen, -150))]); + } + + return response()->json(['ok' => true]); + } + + public function reset() + { + if (auth()->check()) { + AnimeSwipe::where('user_id', auth()->id())->delete(); + } else { + session()->forget('guest_swipes'); + } + return response()->json(['ok' => true]); + } + + public function results(Request $request) + { + // Beğenilen animeler + if (auth()->check()) { + $uid = auth()->id(); + $swipes = AnimeSwipe::where('user_id', $uid) + ->with('anime:id,title,slug,cover_image,rating,release_year,type') + ->orderByDesc('created_at') + ->get()->filter(fn($s) => $s->anime); + + $likedAnimes = $swipes->where('direction', 'like') + ->map(fn($s) => $s->anime) + ->values(); + + $allSwipedIds = $swipes->pluck('anime_id'); + $likeCount = $swipes->where('direction', 'like')->count(); + $skipCount = $swipes->where('direction', 'skip')->count(); + } else { + $seen = session('guest_swipes', []); + $likedAnimes = collect(); + $allSwipedIds= collect($seen); + $likeCount = 0; + $skipCount = count($seen); + } + + // AI önerileri — beğenilen animelerin türlerine benzer, henüz görülmemiş + $recommendations = collect(); + if ($likedAnimes->isNotEmpty()) { + $ai = app(DeepSeekService::class); + + // Beğenilen animelerin genre'larını topla + $likedWithGenres = Anime::whereIn('id', $likedAnimes->pluck('id')) + ->with('genres:id,name') + ->get(); + $genreIds = $likedWithGenres->flatMap(fn($a) => $a->genres->pluck('id'))->unique(); + + // Benzer ama henüz görülmemiş animeler al — ID shuffle ile ORDER BY RAND() önlenir + $candidateIds = Anime::where('is_published', true) + ->whereNotIn('id', $allSwipedIds) + ->whereHas('genres', fn($q) => $q->whereIn('id', $genreIds)) + ->pluck('id') + ->shuffle() + ->take(30); + + $candidateAnimes = Anime::whereIn('id', $candidateIds) + ->with('genres:id,name') + ->withCount('episodes') + ->get() + ->shuffle(); + + if ($ai->isConfigured() && $candidateAnimes->isNotEmpty()) { + $likedTitles = $likedAnimes->pluck('title')->take(5)->join(', '); + $preferences = "Kullanıcının beğendiği animeler: {$likedTitles}. Bunlara benzer, aynı türde ya da aynı atmosferde animeler öner."; + + $candidateData = $candidateAnimes->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'type' => $a->type, + 'release_year' => $a->release_year, + 'rating' => $a->rating, + 'genres' => $a->genres->map(fn($g) => ['name' => $g->name])->toArray(), + ])->values()->toArray(); + + $aiRecs = Cache::remember( + 'dsc_recs_' . md5($likedAnimes->pluck('id')->sort()->join(',')), + 60 * 60 * 6, + fn() => $ai->recommend($preferences, $candidateData) + ); + + if ($aiRecs) { + $recIds = collect($aiRecs)->pluck('id')->map('intval'); + $recAnimes = $candidateAnimes->whereIn('id', $recIds)->keyBy('id'); + + $recommendations = collect($aiRecs)->take(6)->map(function ($rec) use ($recAnimes) { + $anime = $recAnimes->get((int)$rec['id']); + if (!$anime) return null; + return [ + 'id' => $anime->id, + 'slug' => $anime->slug, + 'title' => $anime->title, + 'cover_url' => $anime->coverUrl, + 'rating' => $anime->rating ? number_format($anime->rating, 1) : null, + 'year' => $anime->release_year, + 'genres' => $anime->genres->take(2)->pluck('name')->values(), + 'reason' => $rec['reason'] ?? null, + ]; + })->filter()->values(); + } + } + + // AI yoksa genre-based fallback + if ($recommendations->isEmpty()) { + $recommendations = $candidateAnimes->take(6)->map(fn($a) => [ + 'id' => $a->id, + 'slug' => $a->slug, + 'title' => $a->title, + 'cover_url' => $a->coverUrl, + 'rating' => $a->rating ? number_format($a->rating, 1) : null, + 'year' => $a->release_year, + 'genres' => $a->genres->take(2)->pluck('name')->values(), + 'reason' => null, + ])->values(); + } + } + + return response()->json([ + 'liked' => $likedAnimes->map(fn($a) => [ + 'id' => $a->id, + 'slug' => $a->slug, + 'title' => $a->title, + 'cover_url' => $a->coverUrl, + ])->values(), + 'recommendations' => $recommendations, + 'like_count' => $likeCount, + 'skip_count' => $skipCount, + 'is_auth' => auth()->check(), + ]); + } +} diff --git a/app/Http/Controllers/Frontend/EmailVerificationController.php b/app/Http/Controllers/Frontend/EmailVerificationController.php new file mode 100644 index 0000000..309ec7e --- /dev/null +++ b/app/Http/Controllers/Frontend/EmailVerificationController.php @@ -0,0 +1,65 @@ +user()->email_verified_at) { + return redirect()->route('home'); + } + return view('frontend.auth.verify-email'); + } + + public function verify(Request $request, int $id, string $hash) + { + $user = \App\Models\User::findOrFail($id); + + if (!hash_equals(sha1($user->email), $hash)) { + abort(403); + } + + if (!$user->email_verified_at) { + $user->email_verified_at = now(); + $user->save(); + } + + return redirect()->route('home')->with('status', 'E-posta adresin doğrulandı!'); + } + + public function resend(Request $request) + { + $user = $request->user(); + + if ($user->email_verified_at) { + return back()->with('status', 'E-posta zaten doğrulanmış.'); + } + + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addHours(24), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + Mail::to($user->email)->send(new VerifyEmailMail($url, $user->name)); + + return back()->with('status', 'Doğrulama e-postası tekrar gönderildi.'); + } + + public static function sendVerificationMail(\App\Models\User $user): void + { + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addHours(24), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + Mail::to($user->email)->send(new VerifyEmailMail($url, $user->name)); + } +} diff --git a/app/Http/Controllers/Frontend/HomeController.php b/app/Http/Controllers/Frontend/HomeController.php new file mode 100644 index 0000000..543b6db --- /dev/null +++ b/app/Http/Controllers/Frontend/HomeController.php @@ -0,0 +1,447 @@ +timestamp / 900); // 15 dk = 900 sn + + // ── Latest + Top Rated (cache'li) ──────────────────────────────────── + $latest = cache()->remember("home.latest.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->latest()->take(20)->get() + ); + + $topRated = cache()->remember("home.toprated.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('rating', '>=', 7) + ->orderByDesc('rating')->take(14)->get() + ); + + $genres = cache()->remember('home.genres', 3600, fn() => + Genre::where('is_active', true) + ->withCount(['animes' => fn($q) => $q->where('is_published', true)]) + ->orderByDesc('animes_count') + ->take(16)->get() + ); + + $newEpisodes = cache()->remember("home.newepisodes.{$rotationSlot}", 900, fn() => + Episode::with(['anime', 'season']) + ->where('is_published', true) + ->latest()->take(14)->get() + ); + + // ── Trending: YouTube-benzeri skor ─────────────────────────────────── + $trending = cache()->remember("home.trending.{$rotationSlot}", 900, function () use ($latest) { + try { + // trending_score kolonu varsa kullan (migration çalıştırıldıysa) + $byScore = Anime::where('is_published', true) + ->where(fn($q) => $q->where('trending_score', '>', 0)->orWhere('is_trending', true)) + ->orderByDesc('trending_score') + ->take(12) + ->get(); + + if ($byScore->count() >= 6) return $byScore; + } catch (\Throwable) {} + + // Fallback: manuel + view_count bazlı + $manual = Anime::where('is_trending', true)->where('is_published', true) + ->orderBy('trending_order')->take(12)->get(); + if ($manual->count() >= 6) return $manual->take(12); + + $autoFill = Anime::where('is_published', true) + ->whereNotIn('id', $manual->pluck('id')) + ->withSum(['episodes as recent_views' => fn($q) => + $q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30)) + ], 'view_count') + ->orderByDesc('recent_views') + ->take(12 - $manual->count())->get(); + + $merged = $manual->concat($autoFill); + return $merged->isEmpty() ? $latest->take(12) : $merged; + }); + + // Rotasyon: top 8 sabit, son 4 her 15dk'da shuffle + $top8 = $trending->take(8)->values(); + $bottom4 = $trending->slice(8)->shuffle()->values(); + $trending = $top8->concat($bottom4)->take(12)->values(); + + // ── Devam Ediyor (Bu Sezon) ─────────────────────────────────────────── + $ongoing = cache()->remember("home.ongoing.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('status', 'ongoing') + ->orderByDesc('rating')->take(12)->get() + ); + + // ── Popüler Filmler ─────────────────────────────────────────────────── + $popularMovies = cache()->remember("home.movies.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('type', 'movie') + ->where('rating', '>=', 6)->orderByDesc('rating')->take(12)->get() + ); + + // ── Türkçe Dublaj ───────────────────────────────────────────────────── + $dubbed = cache()->remember("home.dubbed.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('is_dubbed', true) + ->orderByDesc('rating')->take(20)->get() + ); + + // ── Tür Spotlight (2 farklı tür, her birinde top 8 anime) ──────────── + $genreSpotlights = cache()->remember("home.genre_spots.{$rotationSlot}", 900, function () { + $spotGenres = Genre::where('is_active', true) + ->whereIn('name', ['Aksiyon', 'Fantezi', 'Romantik', 'Psikolojik', 'Komedi', 'Spor', 'Macera', 'Drama']) + ->inRandomOrder()->take(3)->get(); + + return $spotGenres->map(fn($g) => [ + 'genre' => $g, + 'animes' => $g->animes() + ->where('is_published', true) + ->where('rating', '>=', 6) + ->orderByDesc('rating') + ->take(8)->get(), + ])->filter(fn($s) => $s['animes']->count() >= 3)->values(); + }); + + // ── Featured Hero Slider: YouTube-benzeri trending algoritması ───────── + $featured = cache()->remember("home.featured.{$rotationSlot}", 900, function () use ($trending, $topRated, $latest) { + // trending_score kolonu var mı? (migration çalıştırılmamışsa fallback) + $hasTrendingScore = \Illuminate\Support\Facades\Schema::hasColumn('animes', 'trending_score'); + + $orderBy = fn($q) => $hasTrendingScore + ? $q->orderByDesc('trending_score') + : $q->orderByDesc('rating'); + + $used = collect(); + + // TIER 1: Son 24 saatte yeni bölüm + trend skoru yüksek + banner + try { + $tier1Ids = Episode::where('is_published', true) + ->where('created_at', '>=', now()->subDay()) + ->pluck('anime_id')->unique()->toArray(); + + $tier1 = $orderBy(Anime::where('is_published', true) + ->whereIn('id', $tier1Ids) + ->whereNotNull('banner_image')) + ->take(6)->get(); + $used = $used->concat($tier1->pluck('id')); + } catch (\Throwable) { + $tier1 = collect(); + } + + // TIER 2: Son 3 günde yeni bölüm + banner + $tier2 = collect(); + if ($tier1->count() < 6) { + try { + $tier2Ids = Episode::where('is_published', true) + ->where('created_at', '>=', now()->subDays(3)) + ->pluck('anime_id')->unique()->diff($used)->toArray(); + + $tier2 = $orderBy(Anime::where('is_published', true) + ->whereIn('id', $tier2Ids) + ->whereNotNull('banner_image')) + ->take(6 - $tier1->count())->get(); + $used = $used->concat($tier2->pluck('id')); + } catch (\Throwable) {} + } + + $combined = $tier1->concat($tier2); + + // TIER 3: Yüksek skor + banner + if ($combined->count() < 6) { + try { + $tier3 = $orderBy(Anime::where('is_published', true) + ->whereNotIn('id', $used->toArray()) + ->whereNotNull('banner_image')) + ->take(6 - $combined->count())->get(); + $used = $used->concat($tier3->pluck('id')); + $combined = $combined->concat($tier3); + } catch (\Throwable) {} + } + + // TIER 4: Trending + topRated (banner olmadan) + if ($combined->count() < 5) { + $fill = $trending->whereNotIn('id', $used->toArray())->take(5 - $combined->count()); + $combined = $combined->concat($fill); + } + if ($combined->count() < 5) { + $fill2 = $topRated->whereNotIn('id', $combined->pluck('id'))->take(5 - $combined->count()); + $combined = $combined->concat($fill2); + } + + return $combined->isEmpty() ? $latest->take(5)->values() : $combined->values(); + }); + + $featured->load('genres', 'seasons', 'episodes'); + + // ── Hero slider JSON ────────────────────────────────────────────────── + $statusLabel = ['ongoing' => 'Devam Ediyor', 'completed' => 'Tamamlandı', 'upcoming' => 'Yakında']; + $featuredSlider = $featured->values()->map(function ($a) use ($statusLabel) { + $firstSeason = $a->seasons->sortBy('season_number')->first(); + $firstEp = $firstSeason + ? $a->episodes->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first() + : null; + return [ + 'title' => $a->title, + 'description' => $a->description, + 'rating' => $a->rating, + 'year' => $a->release_year, + 'episodes' => $a->episode_count, + 'status' => $a->status, + 'statusLabel' => $statusLabel[$a->status] ?? $a->status, + 'slug' => $a->slug, + 'genres' => $a->genres->pluck('name')->values(), + 'coverUrl' => $a->coverUrl, + 'bannerUrl' => $a->bannerUrl, + 'studio' => $a->studio, + 'watchUrl' => ($firstSeason && $firstEp) + ? route('watch', [$a->slug, $firstSeason->season_number, $firstEp->episode_number]) + : null, + 'detailUrl' => route('anime.show', $a->slug), + ]; + })->toArray(); + + // ── Devam Et (auth) ─────────────────────────────────────────────────── + $continueWatching = collect(); + $recommended = collect(); + $userWatchTitles = ''; + + if (auth()->check()) { + try { + $continueWatching = ContinueWatching::where('user_id', auth()->id()) + ->with('anime:id,title,slug,cover_image') + ->where('percent_complete', '>=', 5) + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at') + ->limit(12) + ->get(); + + $watchedIds = ContinueWatching::where('user_id', auth()->id())->pluck('anime_id'); + + if ($watchedIds->isNotEmpty()) { + $topGenreIds = DB::table('anime_genre') + ->whereIn('anime_id', $watchedIds) + ->select('genre_id', DB::raw('count(*) as cnt')) + ->groupBy('genre_id') + ->orderByDesc('cnt') + ->limit(5) + ->pluck('genre_id'); + + if ($topGenreIds->isNotEmpty()) { + $recommended = Anime::where('is_published', true) + ->whereNotIn('id', $watchedIds) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds)) + ->inRandomOrder() + ->take(14) + ->get(); + + // Yeterli değilse rating'e göre topRated'dan dolduralım + if ($recommended->count() < 6) { + $fallback = Anime::where('is_published', true) + ->whereNotIn('id', $watchedIds->merge($recommended->pluck('id'))) + ->where('rating', '>=', 6) + ->inRandomOrder() + ->take(14 - $recommended->count()) + ->get(); + $recommended = $recommended->concat($fallback)->shuffle()->values(); + } + } + + $userWatchTitles = Anime::whereIn('id', $watchedIds->take(8)) + ->pluck('title')->join(', '); + } + } catch (\Throwable $e) {} + } + + // ── Stats ───────────────────────────────────────────────────────────── + $statsAnime = cache()->remember('home.stats.anime', 3600, fn() => Anime::where('is_published', true)->count()); + $statsEpisode = cache()->remember('home.stats.episode', 3600, fn() => Episode::where('is_published', true)->count()); + $statsUser = cache()->remember('home.stats.user', 3600, fn() => User::count()); + $statsGenre = cache()->remember('home.stats.genre', 3600, fn() => Genre::where('is_active', true)->count()); + + $apkUrl = \App\Models\Setting::get('mobile_apk_url', ''); + + // ── Banner reklamlar (premium görmez) ──────────────────────────────── + $bannerAds = ['home_mid' => null, 'home_bottom' => null]; + if (\App\Models\Setting::get('banner_ads_enabled', '0') === '1' + && !(auth()->check() && auth()->user()->isPremium())) { + try { + $bannerAds['home_mid'] = \App\Models\Ad::pickBanner('home_mid'); + $bannerAds['home_bottom'] = \App\Models\Ad::pickBanner('home_bottom'); + } catch (\Throwable $e) {} + } + + return view('frontend.home', compact( + 'featured', 'featuredSlider', 'latest', 'topRated', 'genres', + 'newEpisodes', 'trending', 'continueWatching', 'recommended', 'userWatchTitles', + 'statsAnime', 'statsEpisode', 'statsUser', 'statsGenre', + 'ongoing', 'popularMovies', 'genreSpotlights', 'dubbed', 'apkUrl', 'bannerAds' + )); + } + + public function search() + { + $q = request('q', ''); + $genre = request('genre'); + $type = request('type'); + $status = request('status'); + $year = request('year'); + $sort = request('sort', 'popular'); + + $query = Anime::where('is_published', true) + ->whereNotNull('slug') + ->where('slug', '!=', ''); + + if ($q) { + $query->where(function ($qb) use ($q) { + $qb->where('title', 'like', "%$q%") + ->orWhere('title_en', 'like', "%$q%") + ->orWhere('title_jp', 'like', "%$q%"); + }); + } + if ($genre) { + $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre)); + } + if ($type) { + $query->where('type', $type); + } + if ($status) { + $query->where('status', $status); + } + if ($year) { + $query->where('release_year', $year); + } + + // JSON autocomplete modu + if (request()->boolean('json') || request()->expectsJson()) { + $animes = $query->select('id', 'title', 'title_en', 'cover_image', 'type') + ->latest()->limit(8)->get() + ->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'cover' => $a->coverUrl, + 'type' => $a->type, + ]); + return response()->json(['animes' => $animes]); + } + + // ── Sıralama ────────────────────────────────────────────────────────── + switch ($sort) { + case 'popular': + $query->withSum(['episodes as total_views' => fn($q) => + $q->where('is_published', true) + ], 'view_count')->orderByDesc('total_views'); + break; + + case 'rating': + $query->orderByDesc('rating')->orderByDesc('created_at'); + break; + + case 'newest': + $query->orderByDesc('release_year')->orderByDesc('created_at'); + break; + + case 'oldest': + $query->orderBy('release_year')->orderBy('created_at'); + break; + + case 'az': + $query->orderBy('title'); + break; + + case 'za': + $query->orderByDesc('title'); + break; + + case 'personalized': + if (auth()->check()) { + $watchedIds = \App\Models\ContinueWatching::where('user_id', auth()->id()) + ->pluck('anime_id'); + + $topGenreIds = $watchedIds->isNotEmpty() + ? DB::table('anime_genre') + ->whereIn('anime_id', $watchedIds) + ->select('genre_id', DB::raw('count(*) as cnt')) + ->groupBy('genre_id') + ->orderByDesc('cnt') + ->limit(6) + ->pluck('genre_id') + : collect(); + + if ($topGenreIds->isNotEmpty()) { + $matchingIds = DB::table('anime_genre') + ->whereIn('genre_id', $topGenreIds) + ->pluck('anime_id') + ->unique() + ->values(); + + $idList = $matchingIds->isEmpty() ? '0' : $matchingIds->join(','); + $query->orderByRaw("CASE WHEN animes.id IN ($idList) THEN 0 ELSE 1 END") + ->orderByDesc('rating'); + } else { + $query->orderByDesc('rating'); + } + } else { + $query->orderByDesc('rating'); + } + break; + + default: + $query->orderByDesc('created_at'); + } + + $results = $query->paginate(24)->withQueryString(); + $genres = Genre::where('is_active', true)->get(); + $years = Anime::where('is_published', true)->whereNotNull('release_year') + ->distinct()->orderByDesc('release_year')->pluck('release_year'); + + return view('frontend.search', compact( + 'results', 'genres', 'years', 'q', 'genre', 'type', 'status', 'year', 'sort' + )); + } + + public function searchSuggest() + { + $q = trim(request('q', '')); + if (strlen($q) < 2) { + return response()->json(['results' => []]); + } + $animes = Anime::where('is_published', true) + ->where(function ($qb) use ($q) { + $qb->where('title', 'like', "%$q%") + ->orWhere('title_en', 'like', "%$q%") + ->orWhere('title_jp', 'like', "%$q%"); + }) + ->select('id', 'title', 'title_en', 'slug', 'cover_image', 'type', 'release_year', 'episode_count') + ->orderByRaw("CASE WHEN title LIKE ? THEN 0 ELSE 1 END, title ASC", ["$q%"]) + ->limit(7) + ->get() + ->map(fn($a) => [ + 'title' => $a->title, + 'title_en' => $a->title_en, + 'slug' => $a->slug, + 'cover' => $a->coverUrl, + 'type' => $a->type, + 'year' => $a->release_year, + 'episodes' => $a->episode_count, + ]); + + return response()->json(['results' => $animes]); + } + + public function genre(Genre $genre) + { + $animes = $genre->animes()->where('is_published', true)->latest()->paginate(24); + return view('frontend.genre', compact('genre', 'animes')); + } +} diff --git a/app/Http/Controllers/Frontend/MessageController.php b/app/Http/Controllers/Frontend/MessageController.php new file mode 100644 index 0000000..d031809 --- /dev/null +++ b/app/Http/Controllers/Frontend/MessageController.php @@ -0,0 +1,263 @@ +conversations() + ->with(['participants', 'lastMessage.user']) + ->orderByDesc('conversations.updated_at') + ->get() + ->map(function ($conv) use ($user) { + $other = $conv->participants->firstWhere('id', '!=', $user->id); + return [ + 'id' => $conv->id, + 'other' => $other, + 'last_message' => $conv->lastMessage, + 'unread' => $conv->unreadCountFor($user->id), + 'updated_at' => $conv->updated_at, + ]; + }); + } catch (\Throwable $e) { + $conversations = collect(); + } + + return view('frontend.messages.index', compact('conversations')); + } + + public function show(Conversation $conversation) + { + $user = Auth::user(); + + abort_unless( + $conversation->participants()->where('user_id', $user->id)->exists(), + 403 + ); + + $other = $conversation->participants()->where('user_id', '!=', $user->id)->first(); + + $messages = $conversation->messages() + ->with('user') + ->orderBy('created_at') + ->get(); + + // Mark as read + $conversation->participants() + ->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return view('frontend.messages.show', compact('conversation', 'messages', 'other')); + } + + public function startOrOpen(User $user) + { + $me = Auth::user(); + + if ($me->id === $user->id) abort(422); + + // Find existing conversation between these two users + $conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id)) + ->whereHas('participants', fn($q) => $q->where('user_id', $user->id)) + ->first(); + + if (!$conv) { + $conv = DB::transaction(function () use ($me, $user) { + $c = Conversation::create(); + $c->participants()->attach([$me->id, $user->id]); + return $c; + }); + } + + return redirect()->route('messages.show', $conv); + } + + public function send(Request $request, Conversation $conversation) + { + $user = Auth::user(); + + abort_unless( + $conversation->participants()->where('user_id', $user->id)->exists(), + 403 + ); + + $request->validate(['body' => 'required|string|max:5000']); + + $message = Message::create([ + 'conversation_id' => $conversation->id, + 'user_id' => $user->id, + 'body' => $request->body, + ]); + + $conversation->touch(); + + // Mark sender as read + $conversation->participants() + ->updateExistingPivot($user->id, ['last_read_at' => now()]); + + if ($request->expectsJson()) { + return response()->json([ + 'id' => $message->id, + 'body' => $message->body, + 'user_id' => $user->id, + 'created_at' => $message->created_at->format('H:i'), + 'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null, + 'name' => $user->name, + ]); + } + + return back(); + } + + public function poll(Request $request, Conversation $conversation) + { + $user = Auth::user(); + + abort_unless( + $conversation->participants()->where('user_id', $user->id)->exists(), + 403 + ); + + $after = $request->query('after', 0); + + $messages = $conversation->messages() + ->with('user') + ->where('id', '>', $after) + ->orderBy('created_at') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'body' => $m->body, + 'user_id' => $m->user_id, + 'created_at' => $m->created_at->format('H:i'), + 'avatar' => $m->user->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null, + 'name' => $m->user->name, + ]); + + // Update last_read + $conversation->participants() + ->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json(['messages' => $messages]); + } + + public function unreadCount() + { + $user = Auth::user(); + if (!$user) return response()->json(['count' => 0]); + + $count = 0; + foreach ($user->conversations()->with(['messages'])->get() as $conv) { + $count += $conv->unreadCountFor($user->id); + } + + return response()->json(['count' => $count]); + } + + public function conversationsJson() + { + $user = Auth::user(); + + $convs = $user->conversations() + ->with(['participants', 'lastMessage.user']) + ->orderByDesc('conversations.updated_at') + ->limit(30) + ->get() + ->map(function ($conv) use ($user) { + $other = $conv->participants->firstWhere('id', '!=', $user->id); + $last = $conv->lastMessage; + $unread = $conv->unreadCountFor($user->id); + + $preview = null; + if ($last) { + if (str_starts_with($last->body, 'ANIMESHARE::')) { + try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime paylaştı'); } catch(\Throwable) {} + } elseif (str_starts_with($last->body, 'IMAGE::')) { + $preview = ($last->user_id === $user->id ? 'Sen: ' : '') . '📷 Fotoğraf'; + } elseif (str_starts_with($last->body, 'GIF::')) { + $preview = ($last->user_id === $user->id ? 'Sen: ' : '') . '🎞 GIF'; + } else { + $isMine = $last->user_id === $user->id; + $preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 50); + } + } + + return [ + 'conv_id' => $conv->id, + 'id' => $other?->id, + 'name' => $other?->name ?? 'Silinmiş', + 'avatar' => $other?->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null, + 'last_preview' => $preview, + 'unread' => $unread, + 'time' => $conv->updated_at ? $conv->updated_at->diffForHumans(null, true) : null, + ]; + }); + + return response()->json($convs); + } + + public function uploadImage(Request $request) + { + $request->validate([ + 'image' => 'required|file|image|max:8192|mimes:jpeg,jpg,png,gif,webp', + ]); + + $path = $request->file('image')->store('chat-images', 'public'); + $url = Storage::disk('public')->url($path); + + return response()->json(['url' => $url]); + } + + public function quickShare(Request $request) + { + $request->validate([ + 'to_user_id' => 'required|integer|exists:users,id', + 'body' => 'required|string|max:3000', + ]); + + $me = Auth::user(); + $target = User::findOrFail($request->to_user_id); + + if ($me->id === $target->id) abort(422, 'Kendinize gönderemezsiniz.'); + + $conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id)) + ->whereHas('participants', fn($q) => $q->where('user_id', $target->id)) + ->first(); + + if (!$conv) { + $conv = DB::transaction(function () use ($me, $target) { + $c = Conversation::create(); + $c->participants()->attach([$me->id, $target->id]); + return $c; + }); + } + + $message = Message::create([ + 'conversation_id' => $conv->id, + 'user_id' => $me->id, + 'body' => $request->body, + ]); + + $conv->touch(); + $conv->participants()->updateExistingPivot($me->id, ['last_read_at' => now()]); + + return response()->json([ + 'ok' => true, + 'conversation_id' => $conv->id, + 'message_id' => $message->id, + ]); + } +} diff --git a/app/Http/Controllers/Frontend/PasswordResetController.php b/app/Http/Controllers/Frontend/PasswordResetController.php new file mode 100644 index 0000000..c508410 --- /dev/null +++ b/app/Http/Controllers/Frontend/PasswordResetController.php @@ -0,0 +1,78 @@ +validate(['email' => 'required|email'], [ + 'email.required' => 'E-posta zorunludur.', + 'email.email' => 'Geçerli bir e-posta girin.', + ]); + + $user = User::where('email', $request->email)->first(); + + // Kullanıcı bulunamasa bile aynı mesajı göster (güvenlik) + if ($user) { + $status = Password::sendResetLink( + $request->only('email'), + function (User $user, string $token) { + $url = url(route('password.reset', ['token' => $token, 'email' => $user->email], false)); + Mail::to($user->email)->send(new ResetPasswordMail($url, $user->name)); + } + ); + } + + return back()->with('status', 'Eğer bu e-posta adresine kayıtlı bir hesap varsa şifre sıfırlama bağlantısı gönderildi.'); + } + + public function showReset(Request $request, string $token) + { + return view('frontend.auth.reset-password', [ + 'token' => $token, + 'email' => $request->query('email', ''), + ]); + } + + public function reset(Request $request) + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['required', 'confirmed', PasswordRule::min(6)], + ], [ + 'password.required' => 'Şifre zorunludur.', + 'password.confirmed' => 'Şifreler eşleşmiyor.', + 'password.min' => 'Şifre en az 6 karakter olmalıdır.', + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password) { + $user->forceFill(['password' => Hash::make($password)])->save(); + } + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect()->route('frontend.login') + ->with('status', 'Şifreniz başarıyla sıfırlandı. Giriş yapabilirsiniz.'); + } + + return back()->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Frontend/PlayerController.php b/app/Http/Controllers/Frontend/PlayerController.php new file mode 100644 index 0000000..ad55041 --- /dev/null +++ b/app/Http/Controllers/Frontend/PlayerController.php @@ -0,0 +1,372 @@ +is_published, 404); + + $seasonModel = Season::where('anime_id', $anime->id) + ->where('season_number', $season) + ->firstOrFail(); + + $ep = Episode::where('season_id', $seasonModel->id) + ->where('episode_number', $episode) + ->where('is_published', true) + ->firstOrFail(); + + $ep->increment('view_count'); + $ep->load('subtitles'); + + // Tüm sezonlar + bölümler (playlist için) + türler (bilgi paneli) + $anime->load([ + 'seasons' => fn($q) => $q->orderBy('season_number'), + 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'), + 'genres', + ]); + + // Önceki / sonraki bölüm + $prev = Episode::with('season') + ->where('season_id', $seasonModel->id) + ->where('episode_number', $episode - 1) + ->where('is_published', true) + ->first(); + + $next = Episode::with('season') + ->where('season_id', $seasonModel->id) + ->where('episode_number', $episode + 1) + ->where('is_published', true) + ->first(); + + // Sonraki bölüm yoksa bir sonraki sezona geç + if (!$next) { + $nextSeason = Season::where('anime_id', $anime->id) + ->where('season_number', $season + 1) + ->first(); + if ($nextSeason) { + $next = Episode::where('season_id', $nextSeason->id) + ->where('episode_number', 1) + ->where('is_published', true) + ->first(); + } + } + + // JSON-safe subtitle data (proxy URL for CORS bypass) + $subtitlesData = $ep->subtitles->map(fn($s) => [ + 'label' => $s->label, + 'lang' => $s->language, + 'url' => route('vtt.proxy', ['url' => $s->url]), + 'is_default' => (bool) $s->is_default, + ])->values()->toArray(); + + // Dublaj kaynakları — CDN’deki .../{720p|1080p}-{dub}[/master.m3u8] kalıbından türet + // Embed modda URL video_url’de olabilir (m3u8_url null) — video_url’e fallback + $raw = $ep->available_dubs; + $availableDubs = is_array($raw) ? $raw : null; // null = unknown (show all), array = restrict to listed + $sourceForDubs = $ep->m3u8_url ?: $ep->video_url; + [$dubSources, $activeDub] = self::resolveDubSourcesFromM3u8($sourceForDubs, $availableDubs); + + // Tüm video URL’lerini imzala (BunnyCDN Token Auth) + BunnyCdnSigner::signAll($dubSources); + $ep->m3u8_url = BunnyCdnSigner::sign($ep->m3u8_url); + $ep->video_url = BunnyCdnSigner::sign($ep->video_url); + + // Proxy all external HLS streams through our server (CORS + SSL bypass). + // anizium.co + aniziumserver.* CDN'leri doğrudan yükle (tarayıcı üzerinden). + $isOwn = fn(?string $u) => !$u || str_contains($u, 'b-cdn.net') || str_contains($u, 'animexe.com') + || str_contains($u, 'anizium.co') || str_contains($u, 'aniziumserver.sbs'); + if ($ep->m3u8_url && !$isOwn($ep->m3u8_url)) { + $ep->m3u8_url = route("stream.proxy", ["u" => base64_encode((string) $ep->m3u8_url)]); + } + // video_url — embed modda anizium HLS URL olabilir; HLS ise proxy'den geçir, MP4 ise bırak. + if ($ep->video_url && !$isOwn($ep->video_url)) { + if (str_ends_with((string) $ep->video_url, '.m3u8')) { + $ep->video_url = route("stream.proxy", ["u" => base64_encode((string) $ep->video_url)]); + } + } + foreach (array_keys($dubSources) as $idx) { + $dubUrl = (string) ($dubSources[$idx]["url"] ?? ""); + if ($dubUrl && !$isOwn($dubUrl)) { + $dubSources[$idx]["url"] = route("stream.proxy", ["u" => base64_encode($dubUrl)]); + } + } + + // Video sources — Anizium 1080p → 720p → 4K/diğer, sonra AnimeCix + $videoSourcesData = \App\Models\VideoSource::where('episode_id', $ep->id) + ->orderBy('sort_order') + ->get(['id', 'label', 'url', 'type', 'quality', 'translator_id', 'is_default', 'source', 'sort_order', 'is_hevc']) + ->groupBy(fn($vs) => $vs->translator_id ?: $vs->label) + ->map(function ($group) { + $default = $group->firstWhere('is_default', true) ?? $group->first(); + return [ + 'id' => $default->id, + 'key' => $default->translator_id ?: \Illuminate\Support\Str::slug($default->label), + 'label' => $default->label ?: 'Kaynak', + 'url' => $default->url, + 'type' => $default->type ?? 'hls', + 'source' => $default->source ?? 'animecix', + 'quality' => $default->quality ?? '', + 'sort_order' => $default->sort_order ?? 99, + 'is_hevc' => (bool) $default->is_hevc, + ]; + }) + ->sortBy(function ($item) { + $isAnizium = ($item['source'] === 'anizium'); + $q = strtolower($item['quality'] ?? ''); + if ($isAnizium) { + if (str_contains($q, '1080')) return 0; + if (str_contains($q, '720')) return 1; + return 1000; // 4K / H.265 / diğer → en sona + } + return 10 + ($item['sort_order'] ?? 99); // AnimeCix + }) + ->values() + ->toArray(); + + // Sonraki bölüm URL'si + $nextUrl = null; + if ($next) { + $nextSeasonNum = $next->season?->season_number ?? $seasonModel->season_number; + if (!$next->season) { + $nextSeason2 = Season::find($next->season_id); + $nextSeasonNum = $nextSeason2?->season_number ?? $seasonModel->season_number; + } + $nextUrl = route('watch', [$anime->slug, $nextSeasonNum, $next->episode_number]); + } + + // Intro video ayarları + $introUrl = Setting::get('intro_enabled') == '1' ? (Setting::get('intro_video_url') ?: null) : null; + $introSkipAfter = (int) Setting::get('intro_skip_after', 5); + $mainVideoSkipSec = (int) Setting::get('main_video_skip_seconds', 10); + $wmCoverSeconds = (int) Setting::get('watermark_cover_seconds', 11); + + // İntro atla: önce bölüme elle girilmiş zamanlar, yoksa AniSkip API + $aniSkip = null; + + if ($ep->intro_start !== null && $ep->intro_end !== null && $ep->intro_end > $ep->intro_start) { + // Manuel giriş — en güvenilir + $aniSkip = ['op' => ['start' => (float)$ep->intro_start, 'end' => (float)$ep->intro_end]]; + } else { + $seasonMalId = $seasonModel->mal_id; + + // season.mal_id yoksa akıllı fallback (Jikan'a gitme, bloke olur) + if (!$seasonMalId && $anime->mal_id) { + // Sezon 1 için anime.mal_id direkt kullanılabilir + // Diğer sezonlar için background job yerine cache'li Jikan + if ($seasonModel->season_number === 1) { + $seasonMalId = $anime->mal_id; + $seasonModel->update(['mal_id' => $seasonMalId]); + } else { + // Sequel chain'i sadece cache'li olarak dene (timeout kısa, bloke etmez) + try { + $cacheKey = "jikan_chain_{$anime->mal_id}"; + $chain = \Illuminate\Support\Facades\Cache::get($cacheKey); + if (!$chain) { + // Cache yoksa arka planda doldur, bu istek için atla + dispatch(function () use ($anime) { + $chain = (new \App\Services\JikanService())->fetchSeasonMalIds($anime->mal_id); + if ($chain) { + \Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $s) { + if (!$s->mal_id && isset($chain[$i])) $s->update(['mal_id' => $chain[$i]]); + } + } + })->afterResponse(); + } else { + $idx = $seasonModel->season_number - 1; + $seasonMalId = $chain[$idx] ?? $chain[0] ?? null; + if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]); + } + } catch (\Throwable) {} + } + } + + // anime.mal_id de yoksa Jikan title search (sadece bir kez, cache'lenir) + if (!$seasonMalId && !$anime->mal_id) { + try { + $found = (new AniSkipService())->searchByTitle($anime->title, $anime->title_en, $anime->title_jp); + if ($found) { + $anime->update(['mal_id' => $found]); + $seasonMalId = $found; + if ($seasonModel->season_number === 1) $seasonModel->update(['mal_id' => $found]); + } + } catch (\Throwable) {} + } + + if ($seasonMalId) { + try { + $aniSkip = (new AniSkipService())->getSkipTimes((string)$seasonMalId, $ep->episode_number); + } catch (\Throwable) {} + } + } + + // İzleme ilerlemeleri (sidebar progress bar için) + $watchProgress = []; + if (auth()->check()) { + $progRows = \App\Models\ContinueWatching::where('user_id', auth()->id()) + ->where('anime_id', $anime->id) + ->get(['episode_id', 'percent_complete']); + foreach ($progRows as $row) { + $watchProgress[$row->episode_id] = (int) $row->percent_complete; + } + } + + // Premium kullanıcı HİÇBİR reklam görmez (hem eski VAST hem yeni MP4 sistemi) + $isPremiumUser = auth()->check() && auth()->user()->isPremium(); + + $adsConfig = [ + 'enabled' => !$isPremiumUser && Setting::get('ads_enabled', '0') === '1', + 'vast_url' => Setting::get('ads_vast_url', ''), + 'freq_episodes' => (int) Setting::get('ads_freq_episodes', 4), + 'freq_minutes' => (int) Setting::get('ads_freq_minutes', 10), + ]; + + // ── Kendi MP4 pre-roll reklam sistemi ──────────────────────────────── + // Premium kullanıcı reklam görmez. mode: 'ad' | 'upsell' | null + $vadConfig = ['mode' => null]; + if (!$isPremiumUser && Setting::get('vad_enabled', '0') === '1') { + $upsellPercent = (int) Setting::get('vad_upsell_percent', 20); + $ad = null; + $mode = null; + if (random_int(1, 100) <= $upsellPercent) { + $mode = 'upsell'; + } else { + $ad = \App\Models\Ad::pickVideo(); + if ($ad && $ad->media_url) { + $mode = 'ad'; + } elseif ($upsellPercent > 0) { + $mode = 'upsell'; // hiç video reklam yoksa upsell göster + } + } + $vadConfig = [ + 'mode' => $mode, + 'ad' => $mode === 'ad' ? [ + 'id' => $ad->id, + 'url' => $ad->media_url, + 'click_url' => $ad->click_url, + 'skip_after' => (int) $ad->skip_after, + ] : null, + 'freq_episodes' => (int) Setting::get('vad_freq_episodes', 2), + 'freq_minutes' => (int) Setting::get('vad_freq_minutes', 5), + 'premium_url' => route('premium.plans'), + ]; + } + + // Kendi reklamımız gösterilecekse IMA/VAST devreye girmesin + if (!empty($vadConfig['mode'])) { + $adsConfig['enabled'] = false; + } + + return response() + ->view('frontend.player', compact( + 'anime', 'ep', 'seasonModel', 'prev', 'next', + 'subtitlesData', 'nextUrl', 'dubSources', 'activeDub', + 'introUrl', 'introSkipAfter', 'mainVideoSkipSec', 'wmCoverSeconds', + 'aniSkip', 'watchProgress', 'videoSourcesData', 'adsConfig', 'vadConfig' + )) + ->header('Cache-Control', 'private, no-store, no-cache, must-revalidate') + ->header('Pragma', 'no-cache') + ->header('X-Player-Version', '3'); + } + + /** + * m3u8 URL içinden kalite+dublaj klasörünü bulup diğer dublaj varyantlarının URL'lerini üretir. + * Örnekler: + * - https://f.aniziumserver.sbs/85937/1/1/1080p-original/master.m3u8 + * - https://host/cdn/x/1/01/720p-trdub/ + * - https://xxx.b-cdn.net/.../1080p_endub/index.m3u8 + * + * @return array{0: array, 1: ?string} + */ + protected static function resolveDubSourcesFromM3u8(?string $m3u8Url, ?array $availableDubs = null): array + { + if (!$m3u8Url || ! is_string($m3u8Url)) { + return [[], null]; + } + + $u = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/'); + if ($u === '') { + return [[], null]; + } + + // Sondaki playlist dosyasını çıkar (master.m3u8, index.m3u8, video.m3u8, …) + if (preg_match('#/[^/]+\.m3u8$#i', $u)) { + $u = rtrim(preg_replace('#/[^/]+\.m3u8$#i', '', $u), '/'); + } + + // Son segment: 720p-original, 1080p_trdub, 480p-endub + if (! preg_match('#^(.*)/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)$#', $u, $m)) { + return [[], null]; + } + + $parent = $m[1]; + $quality = $m[2]; + $sep = $m[3]; + $activeDub = strtolower($m[4]); + + $dubLabels = [ + 'trdub' => 'Türkçe Dublaj', + 'original' => 'Japonca (Orijinal)', + 'endub' => 'İngilizce Dublaj', + // Dynamic: any unrecognised key gets a generic label below + ]; + + $raw = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/'); + $suffix = ''; + if (preg_match('#/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)(/.*)$#i', $raw, $tail)) { + $suffix = $tail[4]; + } + + // Which dub keys to include: + // • null → column not yet migrated (legacy): show all standard dubs + // • [] empty array → no dub info, show only active + // • ['trdub','original',...] → restrict to listed keys + if ($availableDubs === null) { + // Bilinmiyor: aktif dub trdub/endub ise orijinal (JP) de büyük ihtimalle var. + // Aktif dub zaten original ise başka dub olmadığını varsay (false positive önle). + $keys = $activeDub !== 'original' ? [$activeDub, 'original'] : [$activeDub]; + } elseif (count($availableDubs) === 0) { + // Bot açıkça "dub bilgisi yok" dedi — sadece aktif + $keys = [$activeDub]; + } else { + $keys = $availableDubs; + } + + // Tekrarları at, aktif dub'ı öne al + $seen = []; + $sources = []; + // Aktif dub her zaman ilk sıraya + if (!in_array($activeDub, $keys)) array_unshift($keys, $activeDub); + foreach ($keys as $key) { + if (isset($seen[$key])) continue; + $seen[$key] = true; + $label = $dubLabels[$key] ?? ucfirst($key) . ' Dublaj'; + $url = $parent . '/' . $quality . $sep . $key . $suffix; + $sources[] = [ + 'key' => $key, + 'label' => $label, + 'url' => $url, + 'active' => $key === $activeDub, + ]; + } + + return [$sources, $activeDub]; + } +} diff --git a/app/Http/Controllers/Frontend/PremiumController.php b/app/Http/Controllers/Frontend/PremiumController.php new file mode 100644 index 0000000..79be11a --- /dev/null +++ b/app/Http/Controllers/Frontend/PremiumController.php @@ -0,0 +1,97 @@ +user(); + + if (!$user->isPremium()) { + return back()->with('error', 'Bu özellik için premium üyelik gerekiyor.'); + } + + $validated = $request->validate([ + 'comment_bg' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::COMMENT_BACKGROUNDS)), + 'comment_glow' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::COMMENT_GLOWS)), + 'username_color' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::USERNAME_COLORS)), + 'username_effect' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::USERNAME_EFFECTS)), + 'profile_frame' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::PROFILE_FRAMES)), + 'profile_badge' => 'nullable|string|max:32', + 'profile_bg' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::PROFILE_BACKGROUNDS)), + 'gif_avatar' => 'nullable|url|max:500', + 'profile_music_url' => 'nullable|url|max:500', + 'comment_signature' => 'nullable|string|max:100', + 'entry_effect' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::ENTRY_EFFECTS)), + 'animated_banner' => 'nullable|boolean', + ]); + + // Her alanı sadece ilgili perk varsa kaydet + $updates = []; + + if ($user->hasPerk('comment_bg')) { + $updates['comment_bg'] = $validated['comment_bg'] ?? null; + } + if ($user->hasPerk('comment_glow')) { + $updates['comment_glow'] = $validated['comment_glow'] ?? null; + } + if ($user->hasPerk('username_color')) { + $updates['username_color'] = $validated['username_color'] ?? null; + } + if ($user->hasPerk('username_effect')) { + $updates['username_effect'] = $validated['username_effect'] ?? null; + } + if ($user->hasPerk('profile_frame')) { + $updates['profile_frame'] = $validated['profile_frame'] ?? null; + } + if ($user->hasPerk('profile_badge')) { + $updates['profile_badge'] = $validated['profile_badge'] ?? null; + } + if ($user->hasPerk('profile_bg')) { + $updates['profile_bg'] = $validated['profile_bg'] ?? null; + } + if ($user->hasPerk('gif_avatar')) { + $updates['gif_avatar'] = $validated['gif_avatar'] ?? null; + } + if ($user->hasPerk('profile_music') && Schema::hasColumn('users', 'profile_music_url')) { + $updates['profile_music_url'] = $validated['profile_music_url'] ?? null; + } + if ($user->hasPerk('comment_signature')) { + $updates['comment_signature'] = $validated['comment_signature'] ?? null; + } + if ($user->hasPerk('entry_effect')) { + $updates['entry_effect'] = $validated['entry_effect'] ?? null; + } + if ($user->hasPerk('animated_banner')) { + $updates['animated_banner'] = $request->boolean('animated_banner'); + } + + if (!empty($updates)) { + $user->update($updates); + } + + return back()->with('success', 'Premium ayarların kaydedildi!'); + } + + /** Public plans/pricing sayfası */ + public function plans() + { + $plans = \App\Models\MembershipPlan::where('is_active', true) + ->where('is_public', true) + ->where(fn($q) => $q->whereNull('visible_until')->orWhere('visible_until', '>', now())) + ->orderBy('sort_order') + ->get(); + + $allFeatures = PremiumFeatures::grouped(); + + return view('frontend.premium.plans', compact('plans', 'allFeatures')); + } +} diff --git a/app/Http/Controllers/Frontend/ProfileController.php b/app/Http/Controllers/Frontend/ProfileController.php new file mode 100644 index 0000000..3a3ccd1 --- /dev/null +++ b/app/Http/Controllers/Frontend/ProfileController.php @@ -0,0 +1,235 @@ +id) + ->where('status', 'approved') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + $commentCount = Comment::where('user_id', $user->id) + ->where('status', 'approved') + ->count(); + + // İzleme listesi (status gruplu) + $watchlistItems = Watchlist::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image,type,rating') + ->orderByDesc('created_at') + ->get() + ->filter(fn($wl) => $wl->anime !== null) + ->groupBy('status'); + + // Devam et listesi + $continueItems = ContinueWatching::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image') + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at') + ->limit(12) + ->get(); + + // İzleme istatistikleri + $watchStats = [ + 'episodes' => ContinueWatching::where('user_id', $user->id)->where('percent_complete', '>=', 70)->count(), + 'hours' => round(ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1), + 'watchlist'=> Watchlist::where('user_id', $user->id)->count(), + 'ratings' => DB::table('anime_ratings')->where('user_id', $user->id)->count(), + ]; + + // Başarımlar + AchievementService::check($user); // yeni kazanılanları kontrol et + $achievements = UserAchievement::where('user_id', $user->id) + ->with('achievement') + ->orderByDesc('earned_at') + ->get(); + + $allAchievements = \App\Models\Achievement::all(); + + // İzleme Heatmap (son 365 gün) + $heatmapRaw = DB::table('analytics_watch_events') + ->where('user_id', $user->id) + ->where('created_at', '>=', now()->subDays(365)) + ->selectRaw('DATE(created_at) as d, COUNT(DISTINCT episode_id) as cnt') + ->groupBy('d') + ->pluck('cnt', 'd') + ->toArray(); + + // Bölüm notları (son 20) + $episodeNotes = EpisodeNote::where('user_id', $user->id) + ->with('episode:id,title,episode_number,anime_id', 'anime:id,title,slug') + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + // Keşfet geçmişi (beğenilenler + geçilenler) + $swipeHistory = AnimeSwipe::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image,rating,release_year,type') + ->orderByDesc('created_at') + ->limit(60) + ->get() + ->filter(fn($s) => $s->anime !== null); + + return view('frontend.profile', compact( + 'user', 'recentComments', 'commentCount', + 'watchlistItems', 'continueItems', 'watchStats', + 'achievements', 'allAchievements', + 'heatmapRaw', 'episodeNotes', 'swipeHistory' + )); + } + + public function publicProfile(\App\Models\User $user) + { + $commentCount = Comment::where('user_id', $user->id)->where('status', 'approved')->count(); + + $watchlistItems = Watchlist::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image,type,rating') + ->orderByDesc('created_at') + ->get() + ->filter(fn($wl) => $wl->anime !== null) + ->groupBy('status'); + + $watchStats = [ + 'episodes' => ContinueWatching::where('user_id', $user->id)->where('percent_complete', '>=', 70)->count(), + 'hours' => round(ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1), + 'watchlist'=> Watchlist::where('user_id', $user->id)->count(), + 'ratings' => DB::table('anime_ratings')->where('user_id', $user->id)->count(), + ]; + + $achievements = UserAchievement::where('user_id', $user->id) + ->with('achievement') + ->where('earned_at', '!=', null) + ->orderByDesc('earned_at') + ->get(); + + $recentComments = Comment::where('user_id', $user->id) + ->where('status', 'approved') + ->orderByDesc('created_at') + ->limit(6) + ->get(); + + $isOwnProfile = Auth::id() === $user->id; + $isFollowing = Auth::check() && !$isOwnProfile ? Auth::user()->isFollowing($user->id) : false; + $followerCount = \App\Models\UserFollow::where('following_id', $user->id)->count(); + $followingCount= \App\Models\UserFollow::where('follower_id', $user->id)->count(); + $compatibility = (Auth::check() && !$isOwnProfile) + ? Auth::user()->compatibilityWith($user) + : null; + + return view('frontend.public-profile', compact( + 'user', 'commentCount', 'watchlistItems', + 'watchStats', 'achievements', 'recentComments', 'isOwnProfile', + 'isFollowing', 'followerCount', 'followingCount', 'compatibility' + )); + } + + public function settings() + { + return view('frontend.profile-settings', ['user' => Auth::user()]); + } + + public function update(Request $request) + { + $user = Auth::user(); + + $data = $request->validate([ + 'name' => 'required|string|max:60', + 'username' => 'nullable|string|max:30|alpha_dash|unique:users,username,' . $user->id, + 'bio' => 'nullable|string|max:300', + 'website' => 'nullable|url|max:200', + 'twitter' => 'nullable|string|max:50', + 'instagram' => 'nullable|string|max:50', + 'discord' => 'nullable|string|max:80', + 'profile_color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', + 'show_watchlist' => 'boolean', + 'show_activity' => 'boolean', + ]); + + // Checkboxlar false gelince request'te bulunmaz + $data['show_watchlist'] = $request->boolean('show_watchlist'); + $data['show_activity'] = $request->boolean('show_activity'); + + // @ işaretlerini temizle + if (isset($data['twitter'])) $data['twitter'] = ltrim($data['twitter'], '@'); + if (isset($data['instagram'])) $data['instagram'] = ltrim($data['instagram'], '@'); + + $user->update($data); + + return back()->with('success', 'Profil güncellendi.'); + } + + public function updateAvatar(Request $request) + { + $request->validate([ + 'avatar' => 'required|image|mimes:jpg,jpeg,png,webp,gif|max:2048', + ]); + + $user = Auth::user(); + + // Eski avatarı sil + if ($user->avatar && Storage::disk('public')->exists($user->avatar)) { + Storage::disk('public')->delete($user->avatar); + } + + $path = $request->file('avatar')->store('avatars', 'public'); + $user->update(['avatar' => $path]); + + return back()->with('success', 'Profil fotoğrafı güncellendi.'); + } + + public function updateBanner(Request $request) + { + $request->validate([ + 'banner' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120', + ]); + + $user = Auth::user(); + + if ($user->banner_image && Storage::disk('public')->exists($user->banner_image)) { + Storage::disk('public')->delete($user->banner_image); + } + + $path = $request->file('banner')->store('banners', 'public'); + $user->update(['banner_image' => $path]); + + return back()->with('success', 'Profil kapak fotoğrafı güncellendi.'); + } + + public function updatePassword(Request $request) + { + $request->validate([ + 'current_password' => 'required', + 'password' => ['required', 'confirmed', Password::min(8)], + ]); + + $user = Auth::user(); + + if (!Hash::check($request->current_password, $user->password)) { + return back()->withErrors(['current_password' => 'Mevcut şifre yanlış.']); + } + + $user->update(['password' => $request->password]); + + return back()->with('success', 'Şifre güncellendi.'); + } +} diff --git a/app/Http/Controllers/Frontend/SocialController.php b/app/Http/Controllers/Frontend/SocialController.php new file mode 100644 index 0000000..19198e3 --- /dev/null +++ b/app/Http/Controllers/Frontend/SocialController.php @@ -0,0 +1,569 @@ +id === $user->id) { + return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422); + } + + $existing = UserFollow::where('follower_id', $me->id) + ->where('following_id', $user->id) + ->first(); + + if ($existing) { + $existing->delete(); + $following = false; + } else { + UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]); + $following = true; + } + + return response()->json([ + 'following' => $following, + 'followers_count' => UserFollow::where('following_id', $user->id)->count(), + ]); + } + + public function card(User $user) + { + $me = Auth::user(); + $isFollowing = $me + ? UserFollow::where('follower_id', $me->id)->where('following_id', $user->id)->exists() + : false; + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'avatar' => $user->avatar ? MediaUrl::fromStoragePath($user->avatar) : null, + 'followers' => UserFollow::where('following_id', $user->id)->count(), + 'following' => UserFollow::where('follower_id', $user->id)->count(), + 'is_following' => $isFollowing, + 'profile_url' => route('user.profile', $user), + 'follow_url' => ($me && $me->id !== $user->id) ? route('user.follow', $user) : null, + 'msg_url' => ($me && $me->id !== $user->id) ? route('messages.start', $user) : null, + 'is_me' => $me && $me->id === $user->id, + ]); + } + + public function compatibility(User $user) + { + $me = Auth::user(); + if (!$me) return response()->json(['score' => 0]); + + return response()->json([ + 'score' => $me->compatibilityWith($user), + ]); + } + + // ───────────────────────────────────────────────────────── + // NicoNico — Timestamp Yorumları + // ───────────────────────────────────────────────────────── + + public function timestampComments(Episode $episode) + { + $comments = EpisodeTimestampComment::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->where('is_hidden', false) + ->orderBy('timestamp_sec') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'user_id' => $c->user_id, + 'timestamp_sec' => $c->timestamp_sec, + 'body' => $c->body, + 'color' => $c->color, + 'username' => $c->user?->username ?? 'misafir', + 'created_at' => $c->created_at, + ]); + + return response()->json(['comments' => $comments]); + } + + public function timestampCommentStore(Request $request, Episode $episode) + { + $data = $request->validate([ + 'timestamp_sec' => 'required|integer|min:0|max:86400', + 'body' => 'required|string|max:100', + 'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', + ]); + + $me = Auth::user(); + + // Flood koruması: aynı kullanıcı 5 saniye içinde 2+ yorum atmasın + $recent = EpisodeTimestampComment::where('user_id', $me->id) + ->where('episode_id', $episode->id) + ->where('created_at', '>=', now()->subSeconds(5)) + ->count(); + + if ($recent >= 2) { + return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429); + } + + $comment = EpisodeTimestampComment::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'timestamp_sec' => $data['timestamp_sec'], + 'body' => $data['body'], + 'color' => $data['color'] ?? '#ffffff', + ]); + + return response()->json(['ok' => true, 'id' => $comment->id]); + } + + // ───────────────────────────────────────────────────────── + // Tahmin Oyunu + // ───────────────────────────────────────────────────────── + + public function predictions(Episode $episode) + { + $me = Auth::id(); + + $predictions = EpisodePrediction::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('vote_count') + ->get() + ->map(fn($p) => [ + 'id' => $p->id, + 'body' => $p->body, + 'is_correct' => $p->is_correct, + 'vote_count' => $p->vote_count, + 'username' => $p->user?->username, + 'is_mine' => $me && $p->user_id === $me, + 'voted' => $me + ? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists() + : false, + 'created_at' => $p->created_at->diffForHumans(), + ]); + + $myPrediction = $me + ? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id + : null; + + return response()->json([ + 'predictions' => $predictions, + 'my_prediction' => $myPrediction, + ]); + } + + public function predictionStore(Request $request, Episode $episode) + { + $me = Auth::user(); + + $data = $request->validate([ + 'body' => 'required|string|min:5|max:280', + ]); + + $existing = EpisodePrediction::where('episode_id', $episode->id) + ->where('user_id', $me->id) + ->first(); + + if ($existing) { + return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422); + } + + $prediction = EpisodePrediction::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $prediction->id]); + } + + public function predictionVote(Request $request, EpisodePrediction $prediction) + { + $me = Auth::user(); + + $existing = PredictionVote::where('prediction_id', $prediction->id) + ->where('user_id', $me->id) + ->first(); + + if ($existing) { + $existing->delete(); + $prediction->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]); + } + + PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]); + $prediction->increment('vote_count'); + + return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]); + } + + // ───────────────────────────────────────────────────────── + // Watch Party + // ───────────────────────────────────────────────────────── + + public function partyCreate(Request $request) + { + $me = Auth::user(); + + $data = $request->validate([ + 'episode_id' => 'required|exists:episodes,id', + 'is_private' => 'boolean', + 'password' => 'nullable|string|max:30', + 'max_members'=> 'nullable|integer|min:2|max:20', + ]); + + // Kullanıcının zaten aktif bir odası varsa sil + WatchParty::where('host_user_id', $me->id)->delete(); + + $party = WatchParty::create([ + 'room_code' => WatchParty::generateCode(), + 'host_user_id' => $me->id, + 'episode_id' => $data['episode_id'], + 'is_private' => $data['is_private'] ?? false, + 'password' => isset($data['password']) ? Hash::make($data['password']) : null, + 'max_members' => $data['max_members'] ?? 10, + ]); + + WatchPartyMember::create([ + 'party_id' => $party->id, + 'user_id' => $me->id, + ]); + + return response()->json([ + 'ok' => true, + 'room_code' => $party->room_code, + 'party_url' => route('watch.party', $party->room_code), + ]); + } + + public function partyJoin(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + // Şifre kontrolü + if ($party->is_private && $party->password) { + $pw = $request->input('password', ''); + if (!Hash::check($pw, $party->password)) { + return response()->json(['error' => 'Yanlış şifre.'], 403); + } + } + + // Kapasite + $activeCount = $party->activeMembers()->count(); + if ($activeCount >= $party->max_members) { + return response()->json(['error' => 'Oda dolu.'], 403); + } + + WatchPartyMember::updateOrCreate( + ['party_id' => $party->id, 'user_id' => $me->id], + ['last_ping' => now()] + ); + + return response()->json([ + 'ok' => true, + 'current_sec' => $party->current_sec, + 'is_playing' => $party->is_playing, + 'host_id' => $party->host_user_id, + 'members' => $this->partyMemberList($party), + ]); + } + + public function partySync(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + // Sadece host senkron durumu güncelleyebilir + if ($party->host_user_id === $me->id) { + $data = $request->validate([ + 'current_sec' => 'required|integer|min:0', + 'is_playing' => 'required|boolean', + ]); + $party->update([ + 'current_sec' => $data['current_sec'], + 'is_playing' => $data['is_playing'], + ]); + } + + // Herkes ping atar + WatchPartyMember::where('party_id', $party->id) + ->where('user_id', $me->id) + ->update(['last_ping' => now()]); + + return response()->json([ + 'current_sec' => $party->fresh()->current_sec, + 'is_playing' => $party->fresh()->is_playing, + 'members' => $this->partyMemberList($party), + ]); + } + + public function partyLeave(string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete(); + + if ($party->host_user_id === $me->id) { + $party->delete(); + return response()->json(['ok' => true, 'dissolved' => true]); + } + + return response()->json(['ok' => true, 'dissolved' => false]); + } + + public function partyShow(string $roomCode) + { + $party = WatchParty::with(['episode.anime', 'host'])->where('room_code', $roomCode)->firstOrFail(); + return view('frontend.watch-party', compact('party')); + } + + private function partyMemberList(WatchParty $party): array + { + return $party->activeMembers()->with('user:id,name,username')->get() + ->map(fn($m) => [ + 'id' => $m->user_id, + 'name' => $m->user?->name, + 'username' => $m->user?->username, + 'is_host' => $m->user_id === $party->host_user_id, + ])->toArray(); + } + + // ───────────────────────────────────────────────────────── + // İlk Kez İzleyenler + // ───────────────────────────────────────────────────────── + + public function firstWatchRegister(Request $request, Episode $episode) + { + $me = Auth::user(); + $sessionId = $request->header('X-Session-ID') ?? session()->getId(); + + FirstWatchSession::updateOrCreate( + [ + 'episode_id' => $episode->id, + 'user_id' => $me?->id, + 'session_id' => $me ? null : $sessionId, + ], + [ + 'is_first_time' => (bool)$request->input('is_first_time', true), + 'last_seen' => now(), + ] + ); + + $count = FirstWatchSession::where('episode_id', $episode->id) + ->where('is_first_time', true) + ->where('last_seen', '>=', now()->subMinutes(10)) + ->count(); + + return response()->json(['ok' => true, 'first_watch_count' => $count]); + } + + public function firstWatchCount(Episode $episode) + { + $count = FirstWatchSession::where('episode_id', $episode->id) + ->where('is_first_time', true) + ->where('last_seen', '>=', now()->subMinutes(10)) + ->count(); + + return response()->json(['count' => $count]); + } + + // ───────────────────────────────────────────────────────── + // Ruh Hali Motoru + // ───────────────────────────────────────────────────────── + + private static array $moodGenres = [ + 'sad' => ['Drama', 'Romantizm'], + 'funny' => ['Komedi', 'Slice of Life'], + 'hype' => ['Aksiyon', 'Shounen', 'Spor'], + 'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'], + 'romance' => ['Romantizm', 'Shoujo'], + 'scary' => ['Korku', 'Supernatural', 'Gerilim'], + ]; + + public function moodRecommend(Request $request) + { + $mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood']; + $genres = self::$moodGenres[$mood] ?? []; + + $animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres)) + ->where('is_published', true) + ->inRandomOrder() + ->limit(6) + ->get(['id', 'title', 'cover_image', 'slug', 'rating']); + + return response()->json([ + 'animes' => $animes->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'cover' => $a->cover_image ? \App\Support\MediaUrl::fromStoragePath($a->cover_image) : null, + 'url' => route('anime.show', $a->slug), + 'rating'=> $a->rating, + ]), + ]); + } + + // ───────────────────────────────────────────────────────── + // Zaman Kapsülü + // ───────────────────────────────────────────────────────── + + public function capsuleStore(Request $request) + { + $me = Auth::user(); + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'message' => 'required|string|min:5|max:1000', + 'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(), + ]); + + $data['user_id'] = $me->id; + + $capsule = TimeCapsule::create($data); + + return response()->json(['ok' => true, 'id' => $capsule->id]); + } + + public function capsuleIndex() + { + $capsules = TimeCapsule::with('anime:id,title,slug,cover_image') + ->where('user_id', Auth::id()) + ->orderBy('unlock_at') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'anime' => $c->anime?->title, + 'anime_url' => $c->anime ? route('anime.show', $c->anime->slug) : null, + 'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null, + 'unlock_at' => $c->unlock_at->format('d.m.Y'), + 'unlocked' => $c->isUnlocked(), + 'opened' => $c->isOpened(), + 'message' => $c->isOpened() || $c->isUnlocked() ? $c->message : null, + 'created_at' => $c->created_at->format('d.m.Y'), + ]); + + return view('frontend.capsules', compact('capsules')); + } + + public function capsuleOpen(TimeCapsule $capsule) + { + if ($capsule->user_id !== Auth::id()) { + return response()->json(['error' => 'Yetkisiz.'], 403); + } + if (!$capsule->isUnlocked()) { + return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422); + } + + $capsule->update(['opened_at' => now()]); + + return response()->json(['ok' => true, 'message' => $capsule->message]); + } + + // ───────────────────────────────────────────────────────── + // Spoiler Kilitli Kutu + // ───────────────────────────────────────────────────────── + + public function spoilerBoxes(Episode $episode) + { + $me = Auth::id(); + $boxes = SpoilerBox::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('likes') + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => [ + 'id' => $b->id, + 'body' => $b->body, + 'is_spoiler' => $b->is_spoiler, + 'spoiler_score' => $b->spoiler_score, + 'likes' => $b->likes, + 'username' => $b->user?->username, + 'name' => $b->user?->name, + 'is_mine' => $me && $b->user_id === $me, + 'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false, + 'created_at' => $b->created_at->diffForHumans(), + ]); + + return response()->json(['boxes' => $boxes]); + } + + public function spoilerBoxStore(Request $request, Episode $episode) + { + $me = Auth::user(); + $data = $request->validate([ + 'body' => 'required|string|min:3|max:600', + ]); + + // AI spoiler tespiti + $isSpoiler = false; + $spoilerScore = 0; + $ai = new DeepSeekService(); + if ($ai->isConfigured()) { + $prompt = "Aşağıdaki metin bir anime bölümü hakkında yazılmış. Bu metin spoiler içeriyor mu? " + . "Sadece JSON döndür: {\"is_spoiler\": true/false, \"score\": 0-100}\n\nMetin: " . $data['body']; + try { + $raw = $ai->checkSpoiler($data['body']); + if ($raw) { + $isSpoiler = $raw['is_spoiler'] ?? false; + $spoilerScore = $raw['score'] ?? 0; + } + } catch (\Throwable $e) {} + } + + $box = SpoilerBox::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + 'is_spoiler' => $isSpoiler, + 'spoiler_score' => $spoilerScore, + ]); + + return response()->json([ + 'ok' => true, + 'id' => $box->id, + 'is_spoiler' => $isSpoiler, + ]); + } + + public function spoilerBoxLike(SpoilerBox $box) + { + $me = Auth::id(); + + $existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first(); + + if ($existing) { + $existing->delete(); + $box->decrement('likes'); + return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]); + } + + SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]); + $box->increment('likes'); + + return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]); + } +} diff --git a/app/Http/Controllers/Frontend/TrackingController.php b/app/Http/Controllers/Frontend/TrackingController.php new file mode 100644 index 0000000..711c29f --- /dev/null +++ b/app/Http/Controllers/Frontend/TrackingController.php @@ -0,0 +1,194 @@ +validate([ + 'page_type' => 'nullable|string|max:30', + 'anime_id' => 'nullable|integer', + 'episode_id' => 'nullable|integer', + 'referrer' => 'nullable|string|max:500', + 'url' => 'nullable|string|max:500', + 'time_on_page'=> 'nullable|integer|min:0|max:86400', + ]); + + $ip = $request->ip(); + $ua = $request->userAgent() ?? ''; + $isBot = (bool) $request->attributes->get('is_bot', false); + $botType= $request->attributes->get('bot_type', null); + $geo = self::geoIp($ip); + $sessId = session()->getId(); + + PageView::create([ + 'user_id' => auth()->id(), + 'session_id' => $sessId, + 'url' => mb_substr($data['url'] ?? $request->header('Referer', ''), 0, 500), + 'page_type' => $data['page_type'] ?? 'other', + 'anime_id' => $data['anime_id'] ?? null, + 'episode_id' => $data['episode_id'] ?? null, + 'ip' => $ip, + 'country' => $geo['country'] ?? null, + 'city' => $geo['city'] ?? null, + 'device' => self::detectDevice($ua), + 'browser' => self::detectBrowser($ua), + 'referrer' => mb_substr($data['referrer'] ?? '', 0, 500) ?: null, + 'is_bot' => $isBot ? 1 : 0, + 'user_agent' => mb_substr($ua, 0, 500), + 'time_on_page'=> $data['time_on_page'] ?? 0, + 'created_at' => now(), + ]); + + // Oturum kaydını oluştur / güncelle + $this->trackSession($sessId, $ip, $ua, $geo, $isBot, $botType, $data); + + return response()->json(['ok' => true]); + } + + /** + * POST /track/watch + */ + public function watch(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|integer', + 'episode_id' => 'nullable|integer', + 'season_number' => 'required|integer|min:1', + 'episode_number' => 'required|integer|min:1', + 'seconds' => 'required|integer|min:0', + 'total' => 'nullable|integer|min:0', + 'percent' => 'nullable|integer|min:0|max:100', + ]); + + WatchEvent::create([ + 'user_id' => auth()->id(), + 'session_id' => session()->getId(), + 'anime_id' => $data['anime_id'], + 'episode_id' => $data['episode_id'] ?? null, + 'season_number' => $data['season_number'], + 'episode_number' => $data['episode_number'], + 'seconds_watched' => $data['seconds'], + 'total_seconds' => $data['total'] ?? 0, + 'percent_complete'=> $data['percent'] ?? 0, + 'created_at' => now(), + ]); + + // Oturum izleme süresini güncelle + try { + DB::table('analytics_sessions') + ->where('session_id', session()->getId()) + ->increment('total_seconds', (int)$data['seconds']); + } catch (\Exception) {} + + return response()->json(['ok' => true]); + } + + /** + * POST /track/session-end — sayfa kapanırken JS'ten gönderilir + */ + public function sessionEnd(Request $request) + { + $data = $request->validate([ + 'time_on_page' => 'nullable|integer|min:0|max:86400', + ]); + + try { + DB::table('analytics_sessions') + ->where('session_id', session()->getId()) + ->update([ + 'last_seen_at' => now(), + 'total_seconds'=> DB::raw('total_seconds + ' . (int)($data['time_on_page'] ?? 0)), + ]); + } catch (\Exception) {} + + return response()->json(['ok' => true]); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private function trackSession(string $sessId, string $ip, string $ua, array $geo, bool $isBot, ?string $botType, array $data): void + { + try { + $existing = DB::table('analytics_sessions')->where('session_id', $sessId)->first(); + + if ($existing) { + DB::table('analytics_sessions') + ->where('session_id', $sessId) + ->update([ + 'pages_visited' => DB::raw('pages_visited + 1'), + 'last_seen_at' => now(), + 'user_id' => auth()->id() ?? $existing->user_id, + ]); + } else { + DB::table('analytics_sessions')->insert([ + 'session_id' => $sessId, + 'user_id' => auth()->id(), + 'ip' => $ip, + 'country' => $geo['country'] ?? null, + 'city' => $geo['city'] ?? null, + 'device' => self::detectDevice($ua), + 'browser' => self::detectBrowser($ua), + 'referrer' => mb_substr($data['referrer'] ?? '', 0, 500) ?: null, + 'landing_page' => mb_substr($data['url'] ?? '', 0, 500) ?: null, + 'pages_visited'=> 1, + 'total_seconds'=> 0, + 'is_bot' => $isBot ? 1 : 0, + 'bot_type' => $botType, + 'user_agent' => mb_substr($ua, 0, 500), + 'started_at' => now(), + 'last_seen_at' => now(), + ]); + } + } catch (\Exception) {} + } + + private static function geoIp(string $ip): array + { + if ($ip === '127.0.0.1' || str_starts_with($ip, '192.168.') || str_starts_with($ip, '10.')) { + return ['country' => 'Yerel', 'city' => 'Localhost']; + } + + return Cache::remember("geo_{$ip}", 86400 * 7, function () use ($ip) { + try { + $r = Http::timeout(2)->get("http://ip-api.com/json/{$ip}?fields=country,city,status"); + if ($r->ok() && $r->json('status') === 'success') { + return ['country' => $r->json('country'), 'city' => $r->json('city')]; + } + } catch (\Exception) {} + return ['country' => null, 'city' => null]; + }); + } + + private static function detectDevice(string $ua): string + { + $ua = strtolower($ua); + if (str_contains($ua, 'tablet') || str_contains($ua, 'ipad')) return 'tablet'; + if (str_contains($ua, 'mobile') || str_contains($ua, 'android') || str_contains($ua, 'iphone')) return 'mobile'; + return 'desktop'; + } + + private static function detectBrowser(string $ua): string + { + if (str_contains($ua, 'Edg/')) return 'Edge'; + if (str_contains($ua, 'OPR/') || str_contains($ua, 'Opera')) return 'Opera'; + if (str_contains($ua, 'Chrome')) return 'Chrome'; + if (str_contains($ua, 'Firefox')) return 'Firefox'; + if (str_contains($ua, 'Safari')) return 'Safari'; + if (str_contains($ua, 'MSIE') || str_contains($ua, 'Trident')) return 'IE'; + return 'Other'; + } +} diff --git a/app/Http/Controllers/Frontend/TribunalController.php b/app/Http/Controllers/Frontend/TribunalController.php new file mode 100644 index 0000000..718cc51 --- /dev/null +++ b/app/Http/Controllers/Frontend/TribunalController.php @@ -0,0 +1,219 @@ +withCount('votes') + ->latest() + ->paginate(15); + + return view('frontend.tribunal.index', compact('tribunals')); + } + + public function show(Tribunal $tribunal) + { + $tribunal->load(['anime', 'episode', 'creator']); + + $me = Auth::id(); + + $myVote = $me + ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') + : null; + + $myArgument = $me + ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() + : null; + + // Tüm tarafların oy sayımları + $allSides = $tribunal->allSides(); + $voteCounts = []; + $total = 0; + foreach (array_keys($allSides) as $key) { + $cnt = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + $voteCounts[$key] = $cnt; + $total += $cnt; + } + + $arguments = TribunalArgument::with('user:id,name,username') + ->where('tribunal_id', $tribunal->id) + ->orderByDesc('vote_count') + ->get() + ->map(function ($arg) use ($me) { + $voted = $me + ? TribunalArgumentVote::where('argument_id', $arg->id)->where('user_id', $me)->exists() + : false; + return [ + 'id' => $arg->id, + 'side' => $arg->side, + 'body' => $arg->body, + 'vote_count' => $arg->vote_count, + 'username' => $arg->user?->username, + 'name' => $arg->user?->name, + 'is_mine' => $me && $arg->user_id === $me, + 'voted' => $voted, + 'created_at' => $arg->created_at->diffForHumans(), + ]; + }); + + return view('frontend.tribunal.show', compact( + 'tribunal', 'myVote', 'myArgument', 'allSides', 'voteCounts', 'total', 'arguments' + )); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'episode_id' => 'nullable|exists:episodes,id', + 'question' => 'required|string|min:10|max:280', + 'side_a' => 'required|string|min:2|max:100', + 'side_b' => 'required|string|min:2|max:100', + 'extra_sides' => 'nullable|array|max:4', + 'extra_sides.*' => 'required|string|min:2|max:100', + 'closes_at' => 'nullable|date|after:now', + ]); + + $data['created_by'] = Auth::id(); + $data['closes_at'] = $data['closes_at'] ?? now()->addDays(7); + $data['extra_sides'] = array_values(array_filter($data['extra_sides'] ?? [])); + + $tribunal = Tribunal::create($data); + + return response()->json([ + 'ok' => true, + 'url' => route('tribunal.show', $tribunal), + ]); + } + + public function vote(Request $request, Tribunal $tribunal) + { + if ($tribunal->status === 'closed') { + return response()->json(['error' => 'Bu dava kapandı.'], 422); + } + + $validSides = array_keys($tribunal->allSides()); + $data = $request->validate(['side' => 'required|in:' . implode(',', $validSides)]); + $me = Auth::id(); + + $existing = TribunalVote::where('tribunal_id', $tribunal->id) + ->where('user_id', $me) + ->first(); + + if ($existing) { + if ($existing->side === $data['side']) { + $existing->delete(); + $voted = null; + } else { + $existing->update(['side' => $data['side']]); + $voted = $data['side']; + } + } else { + TribunalVote::create([ + 'tribunal_id' => $tribunal->id, + 'user_id' => $me, + 'side' => $data['side'], + 'created_at' => now(), + ]); + $voted = $data['side']; + } + + $counts = []; + foreach (array_keys($tribunal->allSides()) as $key) { + $counts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + } + + return response()->json([ + 'voted' => $voted, + 'counts' => $counts, + 'total' => array_sum($counts), + ]); + } + + public function argue(Request $request, Tribunal $tribunal) + { + if ($tribunal->status === 'closed') { + return response()->json(['error' => 'Bu dava kapandı.'], 422); + } + + $validSides = array_keys($tribunal->allSides()); + $data = $request->validate([ + 'side' => 'required|in:' . implode(',', $validSides), + 'body' => 'required|string|min:10|max:500', + ]); + + $me = Auth::id(); + + $existing = TribunalArgument::where('tribunal_id', $tribunal->id) + ->where('user_id', $me) + ->first(); + + if ($existing) { + return response()->json(['error' => 'Bu dava için zaten bir argüman girdiniz.'], 422); + } + + $arg = TribunalArgument::create([ + 'tribunal_id' => $tribunal->id, + 'user_id' => $me, + 'side' => $data['side'], + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $arg->id]); + } + + public function argVote(Request $request, TribunalArgument $argument) + { + $me = Auth::id(); + + $existing = TribunalArgumentVote::where('argument_id', $argument->id) + ->where('user_id', $me) + ->first(); + + if ($existing) { + $existing->delete(); + $argument->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]); + } + + TribunalArgumentVote::create([ + 'argument_id' => $argument->id, + 'user_id' => $me, + 'created_at' => now(), + ]); + $argument->increment('vote_count'); + + return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]); + } + + public function forAnime(Anime $anime) + { + $tribunals = Tribunal::where('anime_id', $anime->id) + ->withCount('votes') + ->latest() + ->get() + ->map(fn($t) => [ + 'id' => $t->id, + 'question' => $t->question, + 'side_a' => $t->side_a, + 'side_b' => $t->side_b, + 'status' => $t->status, + 'url' => route('tribunal.show', $t), + 'votes' => $t->votes_count, + ]); + + return response()->json(['tribunals' => $tribunals]); + } +} diff --git a/app/Http/Controllers/Frontend/UserFeatureController.php b/app/Http/Controllers/Frontend/UserFeatureController.php new file mode 100644 index 0000000..1174c32 --- /dev/null +++ b/app/Http/Controllers/Frontend/UserFeatureController.php @@ -0,0 +1,465 @@ +id()) + ->with(['anime.genres']) + ->orderByDesc('created_at') + ->get() + ->groupBy('status'); + + $continues = ContinueWatching::where('user_id', auth()->id()) + ->with(['anime', 'episode']) + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at') + ->limit(20) + ->get(); + + $achievements = UserAchievement::where('user_id', auth()->id()) + ->with('achievement') + ->orderByDesc('earned_at') + ->get(); + + return view('frontend.profile', compact('items', 'continues', 'achievements')); + } + + public function watchlistToggle(Request $request, Anime $anime) + { + $this->requireAuth(); + + $status = $request->input('status', 'plan'); + if (!array_key_exists($status, Watchlist::STATUSES)) { + $status = 'plan'; + } + + $existing = Watchlist::where('user_id', auth()->id()) + ->where('anime_id', $anime->id) + ->first(); + + if ($existing) { + if ($existing->status === $status) { + $existing->delete(); + $inList = false; + $newStatus = null; + } else { + $existing->update(['status' => $status]); + $inList = true; + $newStatus = $status; + } + } else { + Watchlist::create([ + 'user_id' => auth()->id(), + 'anime_id' => $anime->id, + 'status' => $status, + 'created_at' => now(), + ]); + $inList = true; + $newStatus = $status; + } + + $newlyEarned = AchievementService::check(auth()->user()); + + return response()->json([ + 'in_list' => $inList, + 'status' => $newStatus, + 'status_label' => $newStatus ? (Watchlist::STATUSES[$newStatus] ?? '') : null, + 'achievements' => array_map(fn($a) => ['title' => $a->title, 'icon' => $a->icon, 'color' => $a->color], $newlyEarned), + ]); + } + + // ── Watchlist Export ───────────────────────────────────────────────────── + + public function watchlistExport(Request $request) + { + $user = auth()->user(); + + if (!$user->hasPerk('watchlist_export')) { + abort(403, 'Bu özellik için premium üyelik gerekiyor.'); + } + + $format = in_array($request->query('format'), ['csv', 'json']) ? $request->query('format') : 'json'; + + $items = Watchlist::where('user_id', $user->id) + ->with('anime:id,title,mal_score,genres') + ->orderBy('status') + ->orderByDesc('created_at') + ->get() + ->map(fn($w) => [ + 'title' => $w->anime->title ?? '', + 'status' => $w->status, + 'added_at' => $w->created_at?->toDateString(), + 'mal_score' => $w->anime->mal_score ?? null, + ]); + + if ($format === 'csv') { + $csv = "title,status,added_at,mal_score\n"; + foreach ($items as $row) { + $csv .= '"' . str_replace('"', '""', $row['title']) . '",' + . $row['status'] . ',' + . $row['added_at'] . ',' + . $row['mal_score'] . "\n"; + } + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="watchlist.csv"', + ]); + } + + return response()->json($items, 200, [ + 'Content-Disposition' => 'attachment; filename="watchlist.json"', + ]); + } + + // ── Episode Vote ────────────────────────────────────────────────────────── + + public function episodeVote(Request $request, Episode $episode) + { + $this->requireAuth(); + + $vote = $request->input('vote') == 1 ? 1 : -1; + + $existing = EpisodeVote::where('user_id', auth()->id()) + ->where('episode_id', $episode->id) + ->first(); + + if ($existing) { + if ($existing->vote === $vote) { + $existing->delete(); // toggle off + } else { + $existing->update(['vote' => $vote]); + } + } else { + EpisodeVote::create([ + 'user_id' => auth()->id(), + 'episode_id' => $episode->id, + 'vote' => $vote, + 'created_at' => now(), + ]); + } + + $likes = EpisodeVote::where('episode_id', $episode->id)->where('vote', 1)->count(); + $dislikes = EpisodeVote::where('episode_id', $episode->id)->where('vote', -1)->count(); + $myVote = EpisodeVote::where('user_id', auth()->id())->where('episode_id', $episode->id)->value('vote'); + + return response()->json([ + 'likes' => $likes, + 'dislikes' => $dislikes, + 'my_vote' => $myVote, + ]); + } + + // ── Anime Rating ───────────────────────────────────────────────────────── + + public function animeRate(Request $request, Anime $anime) + { + $this->requireAuth(); + + $rating = (int) $request->input('rating'); + if ($rating < 1 || $rating > 10) { + return response()->json(['error' => 'Geçersiz puan'], 422); + } + + AnimeRating::updateOrCreate( + ['user_id' => auth()->id(), 'anime_id' => $anime->id], + ['rating' => $rating] + ); + + $avg = AnimeRating::where('anime_id', $anime->id)->avg('rating'); + $count = AnimeRating::where('anime_id', $anime->id)->count(); + + // Anime tablosunu güncelle (ağırlıklı ortalama) + $anime->update(['rating' => round($avg, 1)]); + + $newlyEarned = AchievementService::check(auth()->user()); + + return response()->json([ + 'avg' => round($avg, 1), + 'count' => $count, + 'my_rating' => $rating, + 'achievements' => array_map(fn($a) => ['title' => $a->title, 'icon' => $a->icon, 'color' => $a->color], $newlyEarned), + ]); + } + + // ── Continue Watching (güncelleme) ─────────────────────────────────────── + + public function continueWatchingUpdate(Request $request) + { + if (!auth()->check()) { + return response()->json(['ok' => false]); + } + + $data = $request->validate([ + 'anime_id' => 'required|integer', + 'episode_id' => 'required|integer', + 'season_number' => 'required|integer', + 'episode_number' => 'required|integer', + 'seconds' => 'required|integer|min:0', + 'total' => 'nullable|integer|min:0', + 'percent' => 'nullable|integer|min:0|max:100', + ]); + + $userId = auth()->id(); + + ContinueWatching::updateOrCreate( + ['user_id' => $userId, 'anime_id' => $data['anime_id']], + [ + 'episode_id' => $data['episode_id'], + 'season_number' => $data['season_number'], + 'episode_number' => $data['episode_number'], + 'seconds_watched' => $data['seconds'], + 'total_seconds' => $data['total'] ?? 0, + 'percent_complete'=> $data['percent'] ?? 0, + 'updated_at' => now(), + ] + ); + + // stream_history perki yoksa en eski kayıtları silerek 30 limiti uygula + if (!auth()->user()->hasPerk('stream_history')) { + $count = ContinueWatching::where('user_id', $userId)->count(); + if ($count > 30) { + $idsToDelete = ContinueWatching::where('user_id', $userId) + ->orderBy('updated_at') + ->limit($count - 30) + ->pluck('id'); + ContinueWatching::whereIn('id', $idsToDelete)->delete(); + } + } + + // Başarım kontrolü (her 5 bölümde bir — performans için) + if ($data['seconds'] % 300 < 35) { + AchievementService::check(auth()->user()); + } + + return response()->json(['ok' => true]); + } + + // ── Anime İsteği ───────────────────────────────────────────────────────── + + public function requestIndex() + { + $requests = AnimeRequest::withCount('votes') + ->whereIn('status', ['pending', 'approved', 'added']) + ->orderByDesc('vote_count') + ->orderByDesc('created_at') + ->paginate(20); + + $myRequests = auth()->check() + ? AnimeRequest::where('user_id', auth()->id())->orderByDesc('id')->limit(5)->get() + : collect(); + + $votedIds = []; + if (auth()->check()) { + $votedIds = AnimeRequestVote::where('user_id', auth()->id()) + ->pluck('anime_request_id')->toArray(); + } + + return view('frontend.anime-request', compact('requests', 'myRequests', 'votedIds')); + } + + public function requestStore(Request $request) + { + $this->requireAuth(); + + $data = $request->validate([ + 'title' => 'required|string|max:200', + 'original_title' => 'nullable|string|max:200', + 'note' => 'nullable|string|max:1000', + ]); + + // Benzer istek var mı? + $existing = AnimeRequest::whereRaw('LOWER(title) = ?', [strtolower($data['title'])])->first(); + if ($existing) { + // Oy ekle + $voted = AnimeRequestVote::where('anime_request_id', $existing->id) + ->where('user_id', auth()->id()) + ->exists(); + if (!$voted) { + AnimeRequestVote::create(['anime_request_id' => $existing->id, 'user_id' => auth()->id(), 'created_at' => now()]); + $existing->increment('vote_count'); + } + return response()->json(['ok' => true, 'merged' => true, 'request_id' => $existing->id, 'vote_count' => $existing->fresh()->vote_count]); + } + + $req = AnimeRequest::create([ + 'user_id' => auth()->id(), + 'title' => $data['title'], + 'original_title' => $data['original_title'] ?? null, + 'note' => $data['note'] ?? null, + 'status' => 'pending', + 'vote_count' => 1, + ]); + + AnimeRequestVote::create(['anime_request_id' => $req->id, 'user_id' => auth()->id(), 'created_at' => now()]); + + AchievementService::check(auth()->user()); + + return response()->json(['ok' => true, 'merged' => false, 'request_id' => $req->id, 'vote_count' => 1]); + } + + public function requestVote(AnimeRequest $animeRequest) + { + $this->requireAuth(); + + $voted = AnimeRequestVote::where('anime_request_id', $animeRequest->id) + ->where('user_id', auth()->id()) + ->exists(); + + if ($voted) { + AnimeRequestVote::where('anime_request_id', $animeRequest->id) + ->where('user_id', auth()->id()) + ->delete(); + $animeRequest->decrement('vote_count'); + $isVoted = false; + } else { + AnimeRequestVote::create(['anime_request_id' => $animeRequest->id, 'user_id' => auth()->id(), 'created_at' => now()]); + $animeRequest->increment('vote_count'); + $isVoted = true; + } + + return response()->json(['ok' => true, 'voted' => $isVoted, 'vote_count' => $animeRequest->fresh()->vote_count]); + } + + // ── Anime Takip ────────────────────────────────────────────────────────── + + public function followToggle(Anime $anime) + { + $this->requireAuth(); + $userId = auth()->id(); + + $existing = AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->first(); + + if ($existing) { + $existing->delete(); + $following = false; + } else { + AnimeFollow::create(['user_id' => $userId, 'anime_id' => $anime->id]); + $following = true; + } + + $count = AnimeFollow::where('anime_id', $anime->id)->count(); + + return response()->json(['following' => $following, 'count' => $count]); + } + + // ── Bildirimler ─────────────────────────────────────────────────────────── + + public function notificationsIndex() + { + $this->requireAuth(); + + $notifications = UserNotification::where('user_id', auth()->id()) + ->orderByDesc('created_at') + ->paginate(30); + + // Görüntülenince hepsini okundu yap + UserNotification::where('user_id', auth()->id()) + ->whereNull('read_at') + ->update(['read_at' => now()]); + + return view('frontend.notifications', compact('notifications')); + } + + public function notificationsCount() + { + if (!auth()->check()) { + return response()->json(['count' => 0]); + } + $count = UserNotification::where('user_id', auth()->id())->whereNull('read_at')->count(); + return response()->json(['count' => $count]); + } + + // ── Bölüm Notları ───────────────────────────────────────────────────────── + + public function noteStore(Request $request, Episode $episode) + { + $this->requireAuth(); + + $data = $request->validate([ + 'content' => 'required|string|max:500', + 'timestamp_at' => 'nullable|integer|min:0', + ]); + + $note = EpisodeNote::create([ + 'user_id' => auth()->id(), + 'episode_id' => $episode->id, + 'anime_id' => $episode->anime_id, + 'content' => $data['content'], + 'timestamp_at' => $data['timestamp_at'] ?? null, + ]); + + return response()->json([ + 'ok' => true, + 'note' => [ + 'id' => $note->id, + 'content' => $note->content, + 'timestamp_label' => $note->timestamp_label, + 'timestamp_at' => $note->timestamp_at, + 'created_at' => $note->created_at->format('d.m.Y H:i'), + ], + ]); + } + + public function noteDelete(EpisodeNote $note) + { + $this->requireAuth(); + + if ($note->user_id !== auth()->id()) { + abort(403); + } + + $note->delete(); + + return response()->json(['ok' => true]); + } + + public function episodeNotesList(Episode $episode) + { + $this->requireAuth(); + + $notes = EpisodeNote::where('user_id', auth()->id()) + ->where('episode_id', $episode->id) + ->orderBy('timestamp_at') + ->orderBy('created_at') + ->get() + ->map(fn($n) => [ + 'id' => $n->id, + 'content' => $n->content, + 'timestamp_label' => $n->timestamp_label, + 'timestamp_at' => $n->timestamp_at, + 'created_at' => $n->created_at->format('d.m.Y H:i'), + ]); + + return response()->json(['notes' => $notes]); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + + private function requireAuth() + { + if (!auth()->check()) { + abort(401); + } + } +} diff --git a/app/Http/Controllers/Frontend/VoiceCallController.php b/app/Http/Controllers/Frontend/VoiceCallController.php new file mode 100644 index 0000000..411ee1f --- /dev/null +++ b/app/Http/Controllers/Frontend/VoiceCallController.php @@ -0,0 +1,134 @@ +validate(['callee_id' => 'required|integer|exists:users,id']); + $caller = Auth::user(); + $callee = User::findOrFail($request->callee_id); + + if ($caller->id === $callee->id) { + return response()->json(['error' => 'Kendinizi arayamazsınız.'], 422); + } + + // End any previous active calls + VoiceCall::where('caller_id', $caller->id) + ->whereIn('status', ['ringing', 'active']) + ->update(['status' => 'ended', 'ended_at' => now()]); + + $channelName = 'vc_' . Str::random(20); + $call = VoiceCall::create([ + 'caller_id' => $caller->id, + 'callee_id' => $callee->id, + 'channel_name' => $channelName, + 'status' => 'ringing', + ]); + + $callerToken = AgoraTokenService::generateToken($channelName, $caller->id); + $calleeToken = AgoraTokenService::generateToken($channelName, $callee->id); + + return response()->json([ + 'call_id' => $call->id, + 'channel_name' => $channelName, + 'token' => $callerToken, + 'callee' => [ + 'id' => $callee->id, + 'name' => $callee->name, + 'avatar' => $callee->avatar ? \App\Support\MediaUrl::fromStoragePath($callee->avatar) : null, + ], + 'agora_app_id' => env('AGORA_APP_ID', ''), + ]); + } + + public function answer(VoiceCall $call) + { + $user = Auth::user(); + abort_unless($call->callee_id === $user->id, 403); + abort_unless($call->status === 'ringing', 422, 'Call is no longer ringing.'); + + $call->update(['status' => 'active', 'answered_at' => now()]); + + $token = AgoraTokenService::generateToken($call->channel_name, $user->id); + + return response()->json([ + 'channel_name' => $call->channel_name, + 'token' => $token, + 'agora_app_id' => env('AGORA_APP_ID', ''), + 'caller' => [ + 'id' => $call->caller->id, + 'name' => $call->caller->name, + 'avatar' => $call->caller->avatar ? \App\Support\MediaUrl::fromStoragePath($call->caller->avatar) : null, + ], + ]); + } + + public function decline(VoiceCall $call) + { + $user = Auth::user(); + abort_unless($call->callee_id === $user->id || $call->caller_id === $user->id, 403); + abort_unless($call->status === 'ringing', 422); + + $call->update(['status' => 'declined', 'ended_at' => now()]); + + return response()->json(['ok' => true]); + } + + public function end(VoiceCall $call) + { + $user = Auth::user(); + abort_unless($call->callee_id === $user->id || $call->caller_id === $user->id, 403); + + $call->update(['status' => 'ended', 'ended_at' => now()]); + + return response()->json(['ok' => true]); + } + + public function poll(Request $request) + { + $user = Auth::user(); + + // Check for incoming ringing call + $incoming = VoiceCall::where('callee_id', $user->id) + ->where('status', 'ringing') + ->with('caller') + ->latest() + ->first(); + + if ($incoming) { + return response()->json([ + 'type' => 'incoming', + 'call_id' => $incoming->id, + 'caller' => [ + 'id' => $incoming->caller->id, + 'name' => $incoming->caller->name, + 'avatar' => $incoming->caller->avatar ? \App\Support\MediaUrl::fromStoragePath($incoming->caller->avatar) : null, + ], + ]); + } + + // Check if an active call we're in has been ended by the other side + $call_id = $request->query('call_id'); + if ($call_id) { + $call = VoiceCall::find($call_id); + if ($call && in_array($user->id, [$call->caller_id, $call->callee_id])) { + return response()->json([ + 'type' => 'status', + 'status' => $call->status, + ]); + } + } + + return response()->json(['type' => 'none']); + } +} diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php new file mode 100644 index 0000000..9c4638f --- /dev/null +++ b/app/Http/Controllers/MediaController.php @@ -0,0 +1,24 @@ +exists($path), 404); + + return response()->file($disk->path($path), [ + 'Cache-Control' => 'public, max-age=31536000', + ]); + } +} diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php new file mode 100644 index 0000000..258338d --- /dev/null +++ b/app/Http/Controllers/SitemapController.php @@ -0,0 +1,97 @@ +view('sitemap_index', compact('domain')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function main() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $genres = Genre::where('is_active', true)->select('slug', 'updated_at')->get(); + + $staticPages = [ + ['loc' => '/', 'priority' => '1.0', 'changefreq' => 'daily'], + ['loc' => '/search', 'priority' => '0.8', 'changefreq' => 'daily'], + ['loc' => '/anime-request','priority' => '0.5', 'changefreq' => 'weekly'], + ['loc' => '/blog', 'priority' => '0.8', 'changefreq' => 'daily'], + ]; + + return response() + ->view('sitemaps.main', compact('domain', 'genres', 'staticPages')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function animes() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $animes = Anime::where('is_published', true) + ->select('slug', 'updated_at', 'rating', 'cover_image', 'title') + ->orderByDesc('rating') + ->get() + ->each(function ($anime) use ($domain) { + $anime->cover_image_url = $anime->cover_image + ? (str_starts_with($anime->cover_image, 'http') ? $anime->cover_image : $domain . '/storage/' . $anime->cover_image) + : null; + }); + + return response() + ->view('sitemaps.animes', compact('domain', 'animes')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function blog() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $posts = BlogPost::published() + ->select('slug', 'updated_at', 'published_at', 'cover_image', 'title', 'excerpt') + ->orderByDesc('published_at') + ->get(); + + return response() + ->view('sitemaps.blog', compact('domain', 'posts')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function videos() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $animes = Anime::where('is_published', true) + ->with(['episodes' => fn($q) => $q->with('season:id,season_number')->orderBy('episode_number')->limit(1)]) + ->select('id', 'slug', 'title', 'description', 'cover_image', 'updated_at', 'rating') + ->orderByDesc('rating') + ->limit(200) + ->get() + ->each(function ($anime) use ($domain) { + $anime->cover_image_url = $anime->cover_image + ? (str_starts_with($anime->cover_image, 'http') ? $anime->cover_image : $domain . '/storage/' . $anime->cover_image) + : null; + // İlk bölümün izleme URL'si — player_loc için (loc'tan farklı, gerçek player sayfası) + $firstEp = $anime->episodes->first(); + $sNum = $firstEp?->season?->season_number ?? 1; + $eNum = $firstEp?->episode_number ?? 1; + $anime->first_ep_watch_url = $domain . '/watch/' . $anime->slug . '/' . $sNum . '/' . $eNum; + }); + + return response() + ->view('sitemaps.videos', compact('domain', 'animes')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } +} diff --git a/app/Http/Middleware/AdminAccessMiddleware.php b/app/Http/Middleware/AdminAccessMiddleware.php new file mode 100644 index 0000000..a8fdae5 --- /dev/null +++ b/app/Http/Middleware/AdminAccessMiddleware.php @@ -0,0 +1,22 @@ +check()) { + return redirect()->route('admin.login'); + } + + if (!auth()->user()->isModerator()) { + abort(403, 'Bu alana erişim yetkiniz yok.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..9504fe0 --- /dev/null +++ b/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,43 @@ +middleware(['admin']) → admin ONLY (moderators denied) + * ->middleware(['admin:animes.edit']) → admin, OR moderator WITH that permission + */ + public function handle(Request $request, Closure $next, string $permission = null) + { + if (!auth()->check()) { + return redirect()->route('admin.login'); + } + + $user = auth()->user(); + + // Admins always pass + if ($user->isAdmin()) { + return $next($request); + } + + // Must be at least a moderator + if ($user->role !== 'moderator') { + abort(403, 'Bu alana erişim yetkiniz yok.'); + } + + // Moderators always need a specific permission — no blanket access + if (!$permission || !$user->can_mod($permission)) { + if ($request->expectsJson()) { + return response()->json(['error' => 'Bu işlem için yetkiniz yok.'], 403); + } + return back()->with('error', 'Bu sayfaya erişim için gerekli izne sahip değilsiniz.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/BotDetector.php b/app/Http/Middleware/BotDetector.php new file mode 100644 index 0000000..2a2d9b3 --- /dev/null +++ b/app/Http/Middleware/BotDetector.php @@ -0,0 +1,218 @@ +ip(); + $ua = strtolower($request->userAgent() ?? ''); + $path = $request->path(); + + // Skip paths + foreach (self::SKIP_PATHS as $skip) { + if (str_starts_with('/' . $path, $skip)) { + return $next($request); + } + } + + // Güvenilir IP (Google vb.) — tüm kontrolleri atla + foreach (self::TRUSTED_IP_PREFIXES as $prefix) { + if (str_starts_with($ip, $prefix)) { + return $next($request); + } + } + + // Manuel engelli IP kontrolü + if ($this->isBlockedIp($ip)) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'ip_blocked', 'blocked_ip'); + return response('Erişim engellendi.', 403); + } + + // UA boşsa bot olarak işaretle + if (empty($ua)) { + $request->attributes->set('is_bot', true); + $request->attributes->set('bot_type', 'noua'); + $this->logBot($ip, '', '/' . $path, $request->method(), 'allowed', 'no_ua'); + return $next($request); + } + + // Kötü bot mu? + foreach (self::BAD_BOTS as $pattern) { + if (str_contains($ua, $pattern)) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'blocked', $pattern); + return response('', 403); + } + } + + // İyi bot mu? + foreach (self::GOOD_BOTS as $pattern) { + if (str_contains($ua, $pattern)) { + $request->attributes->set('is_bot', true); + $request->attributes->set('bot_type', 'good'); + // İyi botlar için çok agresif rate limit (dakikada 60) + if ($this->isRateLimited($ip, 60, 'good_bot')) { + return response('', 429); + } + return $next($request); + } + } + + // Generic araç mı? + foreach (self::GENERIC_BOTS as $pattern) { + if (str_contains($ua, $pattern)) { + $request->attributes->set('is_bot', true); + $request->attributes->set('bot_type', 'generic'); + if ($this->isRateLimited($ip, 10, 'generic')) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'rate_limited', $pattern); + // 30+ istek → otomatik engelle + $count = Cache::get("bot_count_{$ip}", 0); + if ($count > 30) { + $this->autoBlock($ip, 'Otomatik: dakikada 30+ generic bot isteği'); + } + return response('', 429); + } + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'allowed', $pattern); + return $next($request); + } + } + + // Normal kullanıcı — genel rate limit (dakikada 120 istek) + if ($this->isRateLimited($ip, 120, 'human')) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'rate_limited', 'human_flood'); + $count = Cache::get("bot_count_{$ip}", 0); + if ($count > 200) { + $this->autoBlock($ip, 'Otomatik: dakikada 200+ istek flood'); + } + return response('', 429); + } + + $request->attributes->set('is_bot', false); + return $next($request); + } + + private function isBlockedIp(string $ip): bool + { + return Cache::remember("blocked_ip_{$ip}", 300, function () use ($ip) { + try { + return DB::table('blocked_ips') + ->where('ip', $ip) + ->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->exists(); + } catch (\Exception) { + return false; + } + }); + } + + private function isRateLimited(string $ip, int $maxPerMinute, string $type): bool + { + $key = "rl_{$type}_{$ip}"; + $count = Cache::get($key, 0); + + if ($count === 0) { + Cache::put($key, 1, 60); + } else { + Cache::increment($key); + } + + // Bot count ayrı izle + Cache::put("bot_count_{$ip}", Cache::get("bot_count_{$ip}", 0) + 1, 60); + + return $count >= $maxPerMinute; + } + + private function autoBlock(string $ip, string $reason): void + { + try { + DB::table('blocked_ips')->insertOrIgnore([ + 'ip' => $ip, + 'reason' => $reason, + 'auto_blocked' => 1, + 'blocked_at' => now(), + 'expires_at' => now()->addHours(24), + ]); + Cache::forget("blocked_ip_{$ip}"); + } catch (\Exception) {} + } + + private function logBot(string $ip, ?string $ua, string $path, string $method, string $action, string $botName): void + { + try { + DB::table('analytics_bot_logs')->insert([ + 'ip' => $ip, + 'user_agent' => mb_substr($ua ?? '', 0, 500), + 'path' => mb_substr($path, 0, 500), + 'method' => $method, + 'action' => $action, + 'bot_name' => mb_substr($botName, 0, 100), + 'created_at' => now(), + ]); + } catch (\Exception) {} + } +} diff --git a/app/Http/Middleware/ImportApiMiddleware.php b/app/Http/Middleware/ImportApiMiddleware.php new file mode 100644 index 0000000..3303a25 --- /dev/null +++ b/app/Http/Middleware/ImportApiMiddleware.php @@ -0,0 +1,21 @@ +header('X-Import-Key') ?? $request->query('api_key'); + + if (!$apiKey || $provided !== $apiKey) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurePlayer.php b/app/Http/Middleware/SecurePlayer.php new file mode 100644 index 0000000..62a3161 --- /dev/null +++ b/app/Http/Middleware/SecurePlayer.php @@ -0,0 +1,24 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return $response; + } +} diff --git a/app/Http/Middleware/SeoRedirectMiddleware.php b/app/Http/Middleware/SeoRedirectMiddleware.php new file mode 100644 index 0000000..a0b4a70 --- /dev/null +++ b/app/Http/Middleware/SeoRedirectMiddleware.php @@ -0,0 +1,33 @@ +isMethod('GET')) { + try { + $path = '/' . ltrim($request->path(), '/'); + $redirect = cache()->remember('seo_redirect_' . md5($path), 300, function () use ($path) { + return SeoRedirect::where('from_path', $path)->where('is_active', true)->first(); + }); + + if ($redirect) { + SeoRedirect::where('id', $redirect->id)->increment('hits'); + cache()->forget('seo_redirect_' . md5($path)); + return redirect($redirect->to_path, $redirect->type); + } + } catch (\Throwable $e) { + // DB/cache hatası — redirect yerine normal akışa devam et, site çökmesin + \Illuminate\Support\Facades\Log::error('SeoRedirectMiddleware DB error: ' . $e->getMessage()); + } + } + + return $next($request); + } +} diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 0000000..23c9e49 --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,25 @@ +hasMany(UserAchievement::class); + } +} diff --git a/app/Models/ActivationCode.php b/app/Models/ActivationCode.php new file mode 100644 index 0000000..dfac5b8 --- /dev/null +++ b/app/Models/ActivationCode.php @@ -0,0 +1,59 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(MembershipPlan::class, 'plan_id'); + } + + public function usedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'used_by'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isUsed(): bool + { + return ! is_null($this->used_at); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function isValid(): bool + { + return ! $this->isUsed() && ! $this->isExpired(); + } + + public static function generateCode(): string + { + do { + $hex = strtoupper(bin2hex(random_bytes(6))); + $code = implode('-', str_split($hex, 4)); + } while (self::where('code', $code)->exists()); + + return $code; + } +} diff --git a/app/Models/Ad.php b/app/Models/Ad.php new file mode 100644 index 0000000..72709ba --- /dev/null +++ b/app/Models/Ad.php @@ -0,0 +1,78 @@ + 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + + /** Aktif + zamanlaması uygun reklamlar */ + public function scopeLive(Builder $q): Builder + { + return $q->where('is_active', true) + ->where(fn($s) => $s->whereNull('starts_at')->orWhere('starts_at', '<=', now())) + ->where(fn($s) => $s->whereNull('ends_at')->orWhere('ends_at', '>=', now())); + } + + /** + * Medya URL'si — yüklenen dosya veya dış URL. + * Yüklenen dosyalar /media/{path} route'undan servis edilir (MediaController); + * public/storage symlink'ine bağımlı değil — kapaklar/avatarlarla aynı yol. + */ + public function getMediaUrlAttribute(): ?string + { + if ($this->file_path) return MediaUrl::fromStoragePath($this->file_path); + if ($this->external_url) return $this->external_url; + return null; + } + + /** CTR yüzdesi */ + public function getCtrAttribute(): float + { + return $this->impressions > 0 + ? round($this->clicks / $this->impressions * 100, 2) + : 0.0; + } + + /** Ağırlıklı rastgele seçim — pre-roll video reklam */ + public static function pickVideo(): ?self + { + return self::weightedPick( + self::live()->where('type', 'video')->where('placement', 'preroll')->get() + ); + } + + /** Ağırlıklı rastgele seçim — banner (placement bazlı) */ + public static function pickBanner(string $placement): ?self + { + return self::weightedPick( + self::live()->where('type', 'banner')->where('placement', $placement)->get() + ); + } + + private static function weightedPick($ads): ?self + { + if ($ads->isEmpty()) return null; + $total = max(1, $ads->sum('weight')); + $roll = random_int(1, $total); + foreach ($ads as $ad) { + $roll -= max(1, $ad->weight); + if ($roll <= 0) return $ad; + } + return $ads->first(); + } +} diff --git a/app/Models/Analytics/AiQuery.php b/app/Models/Analytics/AiQuery.php new file mode 100644 index 0000000..9699eca --- /dev/null +++ b/app/Models/Analytics/AiQuery.php @@ -0,0 +1,23 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Analytics/BotLog.php b/app/Models/Analytics/BotLog.php new file mode 100644 index 0000000..7ee6b0c --- /dev/null +++ b/app/Models/Analytics/BotLog.php @@ -0,0 +1,18 @@ + 'datetime']; +} diff --git a/app/Models/Analytics/PageView.php b/app/Models/Analytics/PageView.php new file mode 100644 index 0000000..d09d2ac --- /dev/null +++ b/app/Models/Analytics/PageView.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Models/Analytics/VisitorSession.php b/app/Models/Analytics/VisitorSession.php new file mode 100644 index 0000000..9d2b986 --- /dev/null +++ b/app/Models/Analytics/VisitorSession.php @@ -0,0 +1,28 @@ + 'boolean', + 'started_at' => 'datetime', + 'last_seen_at'=> 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Analytics/WatchEvent.php b/app/Models/Analytics/WatchEvent.php new file mode 100644 index 0000000..226e90b --- /dev/null +++ b/app/Models/Analytics/WatchEvent.php @@ -0,0 +1,30 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } + public function episode() { return $this->belongsTo(Episode::class); } +} diff --git a/app/Models/Anime.php b/app/Models/Anime.php new file mode 100644 index 0000000..09e6aa7 --- /dev/null +++ b/app/Models/Anime.php @@ -0,0 +1,103 @@ + 'boolean', + 'is_published' => 'boolean', + 'is_dubbed' => 'boolean', + 'is_trending' => 'boolean', + 'rating' => 'float', + 'trending_order' => 'integer', + ]; + + protected static function boot() + { + parent::boot(); + static::creating(function ($anime) { + if (empty($anime->slug)) { + $base = Str::slug($anime->title ?: 'anime'); + $slug = $base; + $i = 2; + while (static::where('slug', $slug)->exists()) { + $slug = $base . '-' . $i++; + } + $anime->slug = $slug; + } + }); + static::saving(function ($anime) { + if (empty($anime->slug)) { + $base = Str::slug($anime->title ?: 'anime'); + $slug = $base; + $i = 2; + while (static::where('slug', $slug)->whereKeyNot($anime->id ?? 0)->exists()) { + $slug = $base . '-' . $i++; + } + $anime->slug = $slug; + } + }); + } + + /** Güvenli detail URL — slug null olsa bile çökmez. */ + public function getDetailUrlAttribute(): string + { + return $this->slug ? route('anime.show', $this->slug) : '#'; + } + + public function genres() + { + return $this->belongsToMany(Genre::class, 'anime_genre'); + } + + public function seasons() + { + return $this->hasMany(Season::class)->orderBy('season_number'); + } + + public function episodes() + { + return $this->hasMany(Episode::class); + } + + public function importJobs() + { + return $this->hasMany(ImportJob::class); + } + + public function permissions() + { + return $this->morphMany(ContentPermission::class, 'content', 'content_type', 'content_id'); + } + + /** Cover veya banner URL'sini döndürür (storage veya dış URL) */ + private function imageUrl(?string $path): ?string + { + return MediaUrl::fromStoragePath($path); + } + + public function getCoverUrlAttribute(): ?string { return $this->imageUrl($this->cover_image); } + public function getBannerUrlAttribute(): ?string { return $this->imageUrl($this->banner_image); } + + public function getPermission(string $key): string + { + $override = $this->permissions()->where('permission_key', $key)->first(); + if ($override) return $override->required_membership; + + $global = PermissionSetting::where('key', $key)->first(); + return $global ? $global->required_membership : 'free'; + } +} diff --git a/app/Models/AnimeFollow.php b/app/Models/AnimeFollow.php new file mode 100644 index 0000000..db959af --- /dev/null +++ b/app/Models/AnimeFollow.php @@ -0,0 +1,15 @@ +belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Models/AnimeRating.php b/app/Models/AnimeRating.php new file mode 100644 index 0000000..f7c8b69 --- /dev/null +++ b/app/Models/AnimeRating.php @@ -0,0 +1,13 @@ +belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Models/AnimeRequest.php b/app/Models/AnimeRequest.php new file mode 100644 index 0000000..10019d7 --- /dev/null +++ b/app/Models/AnimeRequest.php @@ -0,0 +1,31 @@ + ['label' => 'Bekliyor', 'color' => '#f0883e'], + 'approved' => ['label' => 'Onaylandı', 'color' => '#3fb950'], + 'rejected' => ['label' => 'Reddedildi', 'color' => '#f85149'], + 'added' => ['label' => 'Eklendi', 'color' => '#79c0ff'], + ]; + + public function user() { return $this->belongsTo(User::class); } + public function votes() { return $this->hasMany(AnimeRequestVote::class); } + + public function hasVotedBy(?User $user, string $ip): bool + { + if ($user) { + return $this->votes()->where('user_id', $user->id)->exists(); + } + return $this->votes()->where('ip', $ip)->exists(); + } +} diff --git a/app/Models/AnimeRequestVote.php b/app/Models/AnimeRequestVote.php new file mode 100644 index 0000000..bdd427b --- /dev/null +++ b/app/Models/AnimeRequestVote.php @@ -0,0 +1,14 @@ + 'datetime']; +} diff --git a/app/Models/AnimeSwipe.php b/app/Models/AnimeSwipe.php new file mode 100644 index 0000000..10d31a5 --- /dev/null +++ b/app/Models/AnimeSwipe.php @@ -0,0 +1,15 @@ + 'datetime']; + + public function anime() { return $this->belongsTo(Anime::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Banner.php b/app/Models/Banner.php new file mode 100644 index 0000000..21a773d --- /dev/null +++ b/app/Models/Banner.php @@ -0,0 +1,17 @@ + 'boolean']; + + public function getImageUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->image); + } +} diff --git a/app/Models/BlogPost.php b/app/Models/BlogPost.php new file mode 100644 index 0000000..601443e --- /dev/null +++ b/app/Models/BlogPost.php @@ -0,0 +1,56 @@ + 'array', + 'faq' => 'array', + 'ai_generated' => 'boolean', + 'published_at' => 'datetime', + ]; + + public function getCoverUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->cover_image); + } + + public function anime(): BelongsTo + { + return $this->belongsTo(Anime::class); + } + + public function scopePublished($q) + { + return $q->where('status', 'published')->whereNotNull('published_at'); + } + + public function getReadableTimeAttribute(): string + { + return $this->reading_time . ' dk okuma'; + } + + public static function generateSlug(string $title): string + { + $slug = Str::slug($title, '-', 'tr'); + $base = $slug; + $i = 1; + while (static::where('slug', $slug)->exists()) { + $slug = $base . '-' . $i++; + } + return $slug; + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 0000000..94bfcd5 --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,46 @@ + 'boolean']; + + public function likes() + { + return $this->hasMany(CommentLike::class); + } + + public function isLikedBy(?int $userId): bool + { + if (!$userId) return false; + return $this->likes()->where('user_id', $userId)->exists(); + } + + public function commentable() + { + return $this->morphTo(); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function parent() + { + return $this->belongsTo(Comment::class, 'parent_id'); + } + + public function replies() + { + return $this->hasMany(Comment::class, 'parent_id'); + } +} diff --git a/app/Models/CommentLike.php b/app/Models/CommentLike.php new file mode 100644 index 0000000..9b69858 --- /dev/null +++ b/app/Models/CommentLike.php @@ -0,0 +1,20 @@ +belongsTo(Comment::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ContentPermission.php b/app/Models/ContentPermission.php new file mode 100644 index 0000000..2d99f18 --- /dev/null +++ b/app/Models/ContentPermission.php @@ -0,0 +1,10 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } + public function episode() { return $this->belongsTo(Episode::class); } +} diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php new file mode 100644 index 0000000..bd0d6e7 --- /dev/null +++ b/app/Models/Conversation.php @@ -0,0 +1,45 @@ +belongsToMany(User::class, 'conversation_participants') + ->withPivot('last_read_at'); + } + + public function messages() + { + return $this->hasMany(Message::class)->orderBy('created_at'); + } + + public function lastMessage() + { + return $this->hasOne(Message::class)->latestOfMany('created_at'); + } + + public function unreadCountFor(int $userId): int + { + $pivot = $this->participants->firstWhere('id', $userId)?->pivot; + $lastRead = $pivot?->last_read_at; + + $q = $this->messages()->where('user_id', '!=', $userId); + if ($lastRead) { + $q->where('created_at', '>', $lastRead); + } + return $q->count(); + } + + // Find existing DM between two users or return null + public static function between(int $a, int $b): ?self + { + return self::whereHas('participants', fn($q) => $q->where('user_id', $a)) + ->whereHas('participants', fn($q) => $q->where('user_id', $b)) + ->whereHas('participants', fn($q) => $q->havingRaw('COUNT(*) = 2'), null, null, fn($q) => $q->select(\DB::raw('COUNT(*)'))) + ->first(); + } +} diff --git a/app/Models/Episode.php b/app/Models/Episode.php new file mode 100644 index 0000000..8a96145 --- /dev/null +++ b/app/Models/Episode.php @@ -0,0 +1,78 @@ + 'boolean', + 'available_dubs' => 'array', + ]; + + public function anime() + { + return $this->belongsTo(Anime::class); + } + + public function season() + { + return $this->belongsTo(Season::class); + } + + public function subtitles() + { + return $this->hasMany(Subtitle::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function permissions() + { + return $this->morphMany(ContentPermission::class, 'content', 'content_type', 'content_id'); + } + + public function getPermission(string $key): string + { + $override = ContentPermission::where('content_type', 'episode') + ->where('content_id', $this->id) + ->where('permission_key', $key) + ->first(); + if ($override) return $override->required_membership; + + // Anime-level permission + $animeOverride = ContentPermission::where('content_type', 'anime') + ->where('content_id', $this->anime_id) + ->where('permission_key', $key) + ->first(); + if ($animeOverride) return $animeOverride->required_membership; + + $global = PermissionSetting::where('key', $key)->first(); + return $global ? $global->required_membership : 'free'; + } + + public function getDurationFormattedAttribute(): string + { + if (!$this->duration) return '-'; + $minutes = intdiv($this->duration, 60); + $seconds = $this->duration % 60; + return sprintf('%d:%02d', $minutes, $seconds); + } + + public function getThumbnailUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->thumbnail); + } +} diff --git a/app/Models/EpisodeNote.php b/app/Models/EpisodeNote.php new file mode 100644 index 0000000..ba4eac4 --- /dev/null +++ b/app/Models/EpisodeNote.php @@ -0,0 +1,21 @@ +belongsTo(User::class); } + public function episode() { return $this->belongsTo(Episode::class); } + public function anime() { return $this->belongsTo(Anime::class); } + + public function getTimestampLabelAttribute(): string + { + if (!$this->timestamp_at) return ''; + $s = $this->timestamp_at; + return sprintf('%d:%02d', intdiv($s, 60), $s % 60); + } +} diff --git a/app/Models/EpisodePrediction.php b/app/Models/EpisodePrediction.php new file mode 100644 index 0000000..0de1d5a --- /dev/null +++ b/app/Models/EpisodePrediction.php @@ -0,0 +1,16 @@ + 'boolean']; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } + public function votes() { return $this->hasMany(PredictionVote::class, 'prediction_id'); } +} diff --git a/app/Models/EpisodeTimestampComment.php b/app/Models/EpisodeTimestampComment.php new file mode 100644 index 0000000..f81a16f --- /dev/null +++ b/app/Models/EpisodeTimestampComment.php @@ -0,0 +1,22 @@ + 'boolean', + 'timestamp_sec' => 'integer', + ]; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/EpisodeVote.php b/app/Models/EpisodeVote.php new file mode 100644 index 0000000..f1b871f --- /dev/null +++ b/app/Models/EpisodeVote.php @@ -0,0 +1,17 @@ + 'datetime', 'vote' => 'integer']; + + public function user() { return $this->belongsTo(User::class); } + public function episode() { return $this->belongsTo(Episode::class); } +} diff --git a/app/Models/FirstWatchSession.php b/app/Models/FirstWatchSession.php new file mode 100644 index 0000000..8f49f1e --- /dev/null +++ b/app/Models/FirstWatchSession.php @@ -0,0 +1,17 @@ + 'boolean', 'last_seen' => 'datetime']; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Genre.php b/app/Models/Genre.php new file mode 100644 index 0000000..58254c2 --- /dev/null +++ b/app/Models/Genre.php @@ -0,0 +1,27 @@ + 'boolean']; + + protected static function boot() + { + parent::boot(); + static::creating(function ($genre) { + if (empty($genre->slug)) { + $genre->slug = Str::slug($genre->name); + } + }); + } + + public function animes() + { + return $this->belongsToMany(Anime::class, 'anime_genre'); + } +} diff --git a/app/Models/ImportJob.php b/app/Models/ImportJob.php new file mode 100644 index 0000000..0f3a2ba --- /dev/null +++ b/app/Models/ImportJob.php @@ -0,0 +1,86 @@ + 'array', + ]; + + public function anime() + { + return $this->belongsTo(Anime::class); + } + + public function getProgressPercentAttribute(): int + { + if ($this->total_episodes === 0) return 0; + return (int) round($this->done_episodes / $this->total_episodes * 100); + } + + public function getStatusColorAttribute(): string + { + return match($this->status) { + 'pending' => 'secondary', + 'fetching' => 'info', + 'downloading' => 'primary', + 'uploading' => 'warning', + 'done' => 'success', + 'failed' => 'danger', + default => 'secondary', + }; + } + + public function getStatusLabelAttribute(): string + { + return match($this->status) { + 'pending' => 'Bekliyor', + 'fetching' => 'Bölümler Alınıyor', + 'downloading' => 'İndiriliyor', + 'uploading' => 'Yükleniyor', + 'done' => 'Tamamlandı', + 'failed' => 'Hata', + default => $this->status, + }; + } + + /** Toplam bölüm sayısını season_ranges'ten hesapla */ + public function buildEpisodeList(): array + { + if (!$this->season_ranges) return []; + $episodes = []; + foreach ($this->season_ranges as $range) { + $season = (int) $range['season']; + $from = (int) $range['from']; + $to = (int) $range['to']; + for ($ep = $from; $ep <= $to; $ep++) { + $episodes[] = ['season' => $season, 'episode' => $ep]; + } + } + return $episodes; + } + + public function getTotalFromRangesAttribute(): int + { + $total = 0; + foreach (($this->season_ranges ?? []) as $r) { + $total += max(0, (int)$r['to'] - (int)$r['from'] + 1); + } + return $total; + } +} diff --git a/app/Models/MembershipPlan.php b/app/Models/MembershipPlan.php new file mode 100644 index 0000000..cca9d22 --- /dev/null +++ b/app/Models/MembershipPlan.php @@ -0,0 +1,35 @@ + 'array', + 'perks' => 'array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'visible_until' => 'datetime', + 'price' => 'float', + 'trial_days' => 'integer', + ]; + + /** Belirli bir perk'in bu planda aktif olup olmadığını döner */ + public function hasPerk(string $key): bool + { + return !empty(($this->perks ?? [])[$key]); + } + + public function subscriptions() + { + return $this->hasMany(Subscription::class, 'plan_id'); + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 0000000..75b4d92 --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,15 @@ + 'datetime']; + + public function user() { return $this->belongsTo(User::class); } + public function conversation() { return $this->belongsTo(Conversation::class); } +} diff --git a/app/Models/ModeratorPermission.php b/app/Models/ModeratorPermission.php new file mode 100644 index 0000000..8c1ba32 --- /dev/null +++ b/app/Models/ModeratorPermission.php @@ -0,0 +1,73 @@ + [ + 'animes.view' => 'Anime listesini görüntüle', + 'animes.create' => 'Yeni anime ekle', + 'animes.edit' => 'Anime düzenle', + 'animes.delete' => 'Anime sil', + 'animes.publish' => 'Anime yayınla / gizle', + ], + 'Bölüm Yönetimi' => [ + 'episodes.view' => 'Bölümleri görüntüle', + 'episodes.create' => 'Bölüm ekle', + 'episodes.edit' => 'Bölüm düzenle', + 'episodes.delete' => 'Bölüm sil', + ], + 'Kullanıcı Yönetimi' => [ + 'users.view' => 'Kullanıcıları görüntüle', + 'users.edit' => 'Kullanıcı bilgilerini düzenle', + 'users.ban' => 'Kullanıcı banla / ban kaldır', + 'users.premium' => 'Premium ver / al', + ], + 'Yorum Yönetimi' => [ + 'comments.view' => 'Yorumları görüntüle', + 'comments.approve' => 'Yorum onayla / reddet', + 'comments.delete' => 'Yorum sil', + 'comments.pin' => 'Yorum sabitle', + ], + 'İçerik Yönetimi' => [ + 'genres.manage' => 'Türleri yönet', + 'banners.manage' => 'Bannerleri yönet', + 'requests.manage' => 'Anime isteklerini yönet', + 'tribunal.manage' => 'Mahkeme yönet', + 'import.manage' => 'Anime import et', + ], + 'Analitik & Raporlar' => [ + 'analytics.view' => 'Analitikleri görüntüle', + 'analytics.bots' => 'Bot analitiğini görüntüle', + 'analytics.block' => 'IP engelle / engel kaldır', + 'analytics.users' => 'Kullanıcı analitiği & aktivite', + ], + 'Bildirimler' => [ + 'notifications.send' => 'Push bildirim gönder', + 'notifications.view' => 'Bildirim geçmişini görüntüle', + ], + ]; + + public static function allKeys(): array + { + return collect(self::$groups)->flatMap(fn($g) => array_keys($g))->values()->all(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function grantedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'granted_by'); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..c32eaaf --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,28 @@ + 'decimal:2', + 'paid_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function plan() + { + return $this->belongsTo(MembershipPlan::class, 'plan_id'); + } +} diff --git a/app/Models/PermissionSetting.php b/app/Models/PermissionSetting.php new file mode 100644 index 0000000..044839c --- /dev/null +++ b/app/Models/PermissionSetting.php @@ -0,0 +1,10 @@ +belongsTo(EpisodePrediction::class, 'prediction_id'); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Season.php b/app/Models/Season.php new file mode 100644 index 0000000..ae03620 --- /dev/null +++ b/app/Models/Season.php @@ -0,0 +1,31 @@ + 'boolean']; + + public function anime() + { + return $this->belongsTo(Anime::class); + } + + public function episodes() + { + return $this->hasMany(Episode::class)->orderBy('episode_number'); + } + + public function getCoverUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->cover_image); + } +} diff --git a/app/Models/SeoKeyword.php b/app/Models/SeoKeyword.php new file mode 100644 index 0000000..cfef35b --- /dev/null +++ b/app/Models/SeoKeyword.php @@ -0,0 +1,14 @@ + 'boolean']; +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..d40ff59 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,20 @@ +value('value') ?? $default; + } + + public static function set(string $key, $value, string $group = 'general'): void + { + static::updateOrCreate(['key' => $key], ['value' => $value, 'group' => $group]); + } +} diff --git a/app/Models/SpoilerBox.php b/app/Models/SpoilerBox.php new file mode 100644 index 0000000..4e84ba6 --- /dev/null +++ b/app/Models/SpoilerBox.php @@ -0,0 +1,18 @@ + 'boolean', + ]; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } + public function boxLikes(){ return $this->hasMany(SpoilerBoxLike::class, 'box_id'); } +} diff --git a/app/Models/SpoilerBoxLike.php b/app/Models/SpoilerBoxLike.php new file mode 100644 index 0000000..77fcb5a --- /dev/null +++ b/app/Models/SpoilerBoxLike.php @@ -0,0 +1,14 @@ +belongsTo(SpoilerBox::class, 'box_id'); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..17cc517 --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,28 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function plan() + { + return $this->belongsTo(MembershipPlan::class, 'plan_id'); + } +} diff --git a/app/Models/Subtitle.php b/app/Models/Subtitle.php new file mode 100644 index 0000000..105af3c --- /dev/null +++ b/app/Models/Subtitle.php @@ -0,0 +1,15 @@ +belongsTo(Episode::class); + } +} diff --git a/app/Models/TimeCapsule.php b/app/Models/TimeCapsule.php new file mode 100644 index 0000000..1615e9f --- /dev/null +++ b/app/Models/TimeCapsule.php @@ -0,0 +1,28 @@ + 'datetime', + 'opened_at' => 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } + + public function isUnlocked(): bool + { + return now()->gte($this->unlock_at); + } + + public function isOpened(): bool + { + return !is_null($this->opened_at); + } +} diff --git a/app/Models/Tribunal.php b/app/Models/Tribunal.php new file mode 100644 index 0000000..fff7402 --- /dev/null +++ b/app/Models/Tribunal.php @@ -0,0 +1,37 @@ + 'datetime', + 'extra_sides' => 'array', + ]; + + // Tüm tarafları ['a'=>'Haklıydı', 'b'=>'Haksızdı', 'c'=>'...'] formatında döndür + public function allSides(): array + { + $sides = ['a' => $this->side_a, 'b' => $this->side_b]; + foreach (($this->extra_sides ?? []) as $i => $label) { + $sides[chr(99 + $i)] = $label; // c, d, e, ... + } + return $sides; + } + + public function anime() { return $this->belongsTo(Anime::class); } + public function episode() { return $this->belongsTo(Episode::class); } + public function creator() { return $this->belongsTo(User::class, 'created_by'); } + public function votes() { return $this->hasMany(TribunalVote::class); } + public function arguments() { return $this->hasMany(TribunalArgument::class); } + + public function voteCountA() { return $this->votes()->where('side', 'a')->count(); } + public function voteCountB() { return $this->votes()->where('side', 'b')->count(); } +} diff --git a/app/Models/TribunalArgument.php b/app/Models/TribunalArgument.php new file mode 100644 index 0000000..3983c44 --- /dev/null +++ b/app/Models/TribunalArgument.php @@ -0,0 +1,14 @@ +belongsTo(Tribunal::class); } + public function user() { return $this->belongsTo(User::class); } + public function argVotes() { return $this->hasMany(TribunalArgumentVote::class, 'argument_id'); } +} diff --git a/app/Models/TribunalArgumentVote.php b/app/Models/TribunalArgumentVote.php new file mode 100644 index 0000000..1f85fa1 --- /dev/null +++ b/app/Models/TribunalArgumentVote.php @@ -0,0 +1,14 @@ +belongsTo(TribunalArgument::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/TribunalVote.php b/app/Models/TribunalVote.php new file mode 100644 index 0000000..c659ad3 --- /dev/null +++ b/app/Models/TribunalVote.php @@ -0,0 +1,14 @@ +belongsTo(Tribunal::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..7d2a986 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,264 @@ + 'datetime', + 'premium_expires_at' => 'datetime', + 'banned_at' => 'datetime', + 'is_banned' => 'boolean', + 'show_watchlist' => 'boolean', + 'show_activity' => 'boolean', + 'animated_banner' => 'boolean', + 'password' => 'hashed', + ]; + } + + /** + * /u/{username} gibi URL'lerde username veya ID ile çözümleme. + * custom_profile_url perki olan kullanıcılar /u/kullanici-adi şeklinde erişilebilir. + */ + public function resolveRouteBinding($value, $field = null): ?self + { + if (is_numeric($value)) { + return static::find($value); + } + return static::where('username', $value) + ->whereNotNull('username') + ->first(); + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } + + public function isModerator(): bool + { + return in_array($this->role, ['admin', 'moderator']); + } + + public function moderatorPermissions() + { + return $this->hasMany(ModeratorPermission::class); + } + + public function activityLogs() + { + return $this->hasMany(UserActivityLog::class); + } + + /** Returns cached permission set for this user. Admins have all permissions. */ + public function can_mod(string $permission): bool + { + if ($this->isAdmin()) return true; + if ($this->role !== 'moderator') return false; + + $key = "mod_perms_{$this->id}"; + $perms = cache()->remember($key, 300, fn() => + ModeratorPermission::where('user_id', $this->id)->pluck('permission')->all() + ); + return in_array($permission, $perms); + } + + /** Flush cached permissions (call after saving changes). */ + public function flushPermCache(): void + { + cache()->forget("mod_perms_{$this->id}"); + } + + /** isPremium() sonucunu istek başına önbellekle — sayfa başına onlarca kez çağrılıyor */ + private ?bool $_isPremiumCache = null; + + public function isPremium(): bool + { + if ($this->_isPremiumCache !== null) { + return $this->_isPremiumCache; + } + + // 1. yol: membership alanı 'premium' ve süresi dolmamış + $viaMembership = $this->membership === 'premium' + && ($this->premium_expires_at === null || $this->premium_expires_at->isFuture()); + if ($viaMembership) { + return $this->_isPremiumCache = true; + } + + // 2. yol: aktif abonelik var ama membership alanı senkronize değil + // (2 farklı premium yolu — biri güncellenmezse kullanıcı yine premium sayılır) + try { + $viaSubscription = $this->subscriptions() + ->where('status', 'active') + ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->exists(); + } catch (\Throwable) { + $viaSubscription = false; + } + + return $this->_isPremiumCache = $viaSubscription; + } + + /** + * Kullanıcının aktif planında belirli bir perk var mı? + * Ücretsiz kullanıcılarda her zaman false döner. + */ + public function hasPerk(string $key): bool + { + if (!$this->isPremium()) return false; + + // Ücretsiz mod: tüm perkler herkese açık + if (self::freeModeActive()) return true; + + $sub = $this->subscriptions() + ->where('status', 'active') + ->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->latest() + ->with('plan') + ->first(); + + if (!$sub?->plan) return false; + + $perks = $sub->plan->perks ?? []; + return !empty($perks[$key]); + } + + /** premium_free_mode ayarını 5dk cache'leyerek okur */ + public static function freeModeActive(): bool + { + return cache()->remember('premium_free_mode', 300, fn() => + \App\Models\Setting::get('premium_free_mode', '0') + ) === '1'; + } + + /** + * Kullanıcının aktif avatar URL'si: GIF avatar varsa ve hasPerk('gif_avatar') ise döner. + */ + public function effectiveAvatar(): ?string + { + if ($this->gif_avatar && $this->hasPerk('gif_avatar')) { + return $this->gif_avatar; + } + return $this->avatar; + } + + public function subscriptions() + { + return $this->hasMany(Subscription::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + + // ── Social ──────────────────────────────────────────────────────────────── + + public function followers() + { + return $this->belongsToMany(User::class, 'user_follows', 'following_id', 'follower_id') + ->withPivot('created_at'); + } + + public function following() + { + return $this->belongsToMany(User::class, 'user_follows', 'follower_id', 'following_id') + ->withPivot('created_at'); + } + + public function isFollowing(int $userId): bool + { + return \App\Models\UserFollow::where('follower_id', $this->id) + ->where('following_id', $userId) + ->exists(); + } + + public function conversations() + { + return $this->belongsToMany(Conversation::class, 'conversation_participants') + ->withPivot('last_read_at'); + } + + public function totalUnreadMessages(): int + { + return $this->conversations() + ->with(['messages' => fn($q) => $q->where('user_id', '!=', $this->id)]) + ->get() + ->sum(fn($c) => $c->unreadCountFor($this->id)); + } + + // Anime zevk uyum skoru (0-100) + public function compatibilityWith(User $other): int + { + $myIds = \App\Models\Watchlist::where('user_id', $this->id)->pluck('anime_id'); + $theirIds = \App\Models\Watchlist::where('user_id', $other->id)->pluck('anime_id'); + + if ($myIds->isEmpty() || $theirIds->isEmpty()) return 0; + + $mySet = $myIds->unique()->values(); + $theirSet = $theirIds->unique()->values(); + $intersection = $mySet->intersect($theirSet)->count(); + $union = $mySet->merge($theirSet)->unique()->count(); + + $jaccard = $union > 0 ? $intersection / $union : 0; + + // Rating similarity bonus + $myRatings = \DB::table('anime_ratings')->where('user_id', $this->id)->pluck('rating', 'anime_id'); + $theirRatings = \DB::table('anime_ratings')->where('user_id', $other->id)->pluck('rating', 'anime_id'); + $commonAnimes = $myRatings->keys()->intersect($theirRatings->keys()); + + $ratingScore = 0; + if ($commonAnimes->count() > 0) { + $diffs = $commonAnimes->map(fn($id) => abs($myRatings[$id] - $theirRatings[$id]) / 10); + $ratingScore = 1 - $diffs->avg(); + } + + $score = $jaccard * 0.6 + $ratingScore * 0.4; + return (int) round(min($score * 100, 100)); + } + + /** + * İzleme saatine göre rank bilgisi döner. + * watch_rank perki yoksa null döner. + */ + public function watchRank(): ?array + { + if (!$this->hasPerk('watch_rank')) return null; + + $totalSeconds = \App\Models\ContinueWatching::where('user_id', $this->id) + ->sum('seconds_watched'); + $hours = $totalSeconds / 3600; + + return match(true) { + $hours >= 500 => ['label' => 'Efsane', 'color' => '#ff2d7d', 'icon' => 'bi-trophy-fill'], + $hours >= 250 => ['label' => 'Usta', 'color' => '#ffd700', 'icon' => 'bi-star-fill'], + $hours >= 100 => ['label' => 'Bağımlı', 'color' => '#b84dff', 'icon' => 'bi-heart-fill'], + $hours >= 50 => ['label' => 'Hayran', 'color' => '#00f5ff', 'icon' => 'bi-eye-fill'], + $hours >= 20 => ['label' => 'İzleyici', 'color' => '#00d4a4', 'icon' => 'bi-play-circle-fill'], + default => ['label' => 'Acemi', 'color' => '#9ca3af', 'icon' => 'bi-controller'], + }; + } +} diff --git a/app/Models/UserAchievement.php b/app/Models/UserAchievement.php new file mode 100644 index 0000000..76efe6c --- /dev/null +++ b/app/Models/UserAchievement.php @@ -0,0 +1,17 @@ + 'datetime']; + + public function user() { return $this->belongsTo(User::class); } + public function achievement() { return $this->belongsTo(Achievement::class); } +} diff --git a/app/Models/UserActivityLog.php b/app/Models/UserActivityLog.php new file mode 100644 index 0000000..49b4ccd --- /dev/null +++ b/app/Models/UserActivityLog.php @@ -0,0 +1,45 @@ + 'array', 'is_bot' => 'boolean']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // Human-readable action labels + public static array $actionLabels = [ + 'login' => 'Giriş', + 'logout' => 'Çıkış', + 'register' => 'Kayıt', + 'pageview' => 'Sayfa Görüntüleme', + 'anime_view' => 'Anime Görüntüleme', + 'episode_watch' => 'Bölüm İzleme', + 'comment_create' => 'Yorum', + 'watchlist_add' => 'Listeye Ekle', + 'watchlist_remove'=> 'Listeden Çıkar', + 'rating' => 'Puan Verdi', + 'follow' => 'Takip', + 'search' => 'Arama', + 'download' => 'İndirme', + 'capsule_create' => 'Kapsül Oluşturdu', + 'tribunal_vote' => 'Mahkeme Oyu', + 'prediction_vote' => 'Tahmin Oyu', + 'nico_comment' => 'Nico Yorum', + 'password_change' => 'Şifre Değiştirdi', + 'profile_update' => 'Profil Güncelledi', + ]; +} diff --git a/app/Models/UserFollow.php b/app/Models/UserFollow.php new file mode 100644 index 0000000..8dcb519 --- /dev/null +++ b/app/Models/UserFollow.php @@ -0,0 +1,14 @@ +belongsTo(User::class, 'follower_id'); } + public function following() { return $this->belongsTo(User::class, 'following_id'); } +} diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php new file mode 100644 index 0000000..5ec1138 --- /dev/null +++ b/app/Models/UserNotification.php @@ -0,0 +1,25 @@ + 'array', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + + public function getIsReadAttribute(): bool + { + return $this->read_at !== null; + } +} diff --git a/app/Models/VideoSource.php b/app/Models/VideoSource.php new file mode 100644 index 0000000..c9d58f3 --- /dev/null +++ b/app/Models/VideoSource.php @@ -0,0 +1,25 @@ + 'boolean', + 'is_hevc' => 'boolean', + 'hevc_checked_at' => 'datetime', + ]; + + public function episode() + { + return $this->belongsTo(Episode::class); + } +} diff --git a/app/Models/VoiceCall.php b/app/Models/VoiceCall.php new file mode 100644 index 0000000..02eb043 --- /dev/null +++ b/app/Models/VoiceCall.php @@ -0,0 +1,33 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; + + public function caller(): BelongsTo + { + return $this->belongsTo(User::class, 'caller_id'); + } + + public function callee(): BelongsTo + { + return $this->belongsTo(User::class, 'callee_id'); + } + + public function isActive(): bool + { + return in_array($this->status, ['ringing', 'active']); + } +} diff --git a/app/Models/WatchParty.php b/app/Models/WatchParty.php new file mode 100644 index 0000000..bf8642a --- /dev/null +++ b/app/Models/WatchParty.php @@ -0,0 +1,40 @@ + 'boolean', + 'is_private' => 'boolean', + 'current_sec'=> 'integer', + 'synced_at' => 'datetime', + ]; + + public function host() { return $this->belongsTo(User::class, 'host_user_id'); } + public function episode() { return $this->belongsTo(Episode::class); } + public function members() { return $this->hasMany(WatchPartyMember::class, 'party_id'); } + + public function activeMembers() + { + return $this->members()->where('last_ping', '>=', now()->subSeconds(30)); + } + + public static function generateCode(): string + { + do { + $code = strtoupper(Str::random(6)); + } while (self::where('room_code', $code)->exists()); + + return $code; + } +} diff --git a/app/Models/WatchPartyMember.php b/app/Models/WatchPartyMember.php new file mode 100644 index 0000000..36917f8 --- /dev/null +++ b/app/Models/WatchPartyMember.php @@ -0,0 +1,17 @@ + 'datetime', 'last_ping' => 'datetime']; + + public function party() { return $this->belongsTo(WatchParty::class, 'party_id'); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Watchlist.php b/app/Models/Watchlist.php new file mode 100644 index 0000000..ac9c137 --- /dev/null +++ b/app/Models/Watchlist.php @@ -0,0 +1,22 @@ + 'datetime', 'updated_at' => 'datetime']; + + const STATUSES = [ + 'plan' => 'İzlenecek', + 'watching' => 'İzleniyor', + 'completed' => 'Tamamlandı', + 'dropped' => 'Bırakıldı', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..7ca6cf9 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,49 @@ + \App\Models\Episode::class, + 'anime' => \App\Models\Anime::class, + ]); + + \Event::listen(SocialiteWasCalled::class, \SocialiteProviders\Discord\DiscordExtendSocialite::class); + + $this->loadSmtpFromDb(); + } + + private function loadSmtpFromDb(): void + { + try { + if (!\Schema::hasTable('settings')) return; + + $keys = ['mail_host','mail_port','mail_username','mail_password', + 'mail_from_address','mail_from_name','mail_encryption']; + $rows = \App\Models\Setting::whereIn('key', $keys)->pluck('value', 'key'); + + if ($rows->isEmpty() || !$rows->get('mail_host')) return; + + Config::set('mail.mailers.smtp.host', $rows->get('mail_host', '')); + Config::set('mail.mailers.smtp.port', $rows->get('mail_port', 587)); + Config::set('mail.mailers.smtp.username', $rows->get('mail_username', '')); + Config::set('mail.mailers.smtp.password', $rows->get('mail_password', '')); + Config::set('mail.mailers.smtp.encryption', $rows->get('mail_encryption', 'tls')); + Config::set('mail.from.address', $rows->get('mail_from_address', '')); + Config::set('mail.from.name', $rows->get('mail_from_name', config('app.name'))); + Config::set('mail.default', 'smtp'); + } catch (\Throwable) { + // DB henüz hazır değilse sessizce geç + } + } +} diff --git a/app/Services/AchievementService.php b/app/Services/AchievementService.php new file mode 100644 index 0000000..46088ea --- /dev/null +++ b/app/Services/AchievementService.php @@ -0,0 +1,63 @@ +id)->pluck('achievement_id')->toArray(); + + $newlyEarned = []; + + foreach ($allAchievements as $ach) { + if (in_array($ach->id, $earned)) continue; + + $met = match ($ach->condition_type) { + 'episodes_watched' => self::episodesWatched($user) >= $ach->condition_value, + 'hours_watched' => self::hoursWatched($user) >= $ach->condition_value, + 'watchlist_count' => Watchlist::where('user_id', $user->id)->count() >= $ach->condition_value, + 'anime_rated' => DB::table('anime_ratings')->where('user_id', $user->id)->count() >= $ach->condition_value, + 'request_sent' => DB::table('anime_requests')->where('user_id', $user->id)->count() >= $ach->condition_value, + 'first_login' => true, + default => false, + }; + + if ($met) { + UserAchievement::firstOrCreate([ + 'user_id' => $user->id, + 'achievement_id' => $ach->id, + ], ['earned_at' => now()]); + $newlyEarned[] = $ach; + } + } + + return $newlyEarned; + } + + private static function episodesWatched(User $user): int + { + return ContinueWatching::where('user_id', $user->id) + ->where('percent_complete', '>=', 70) + ->count(); + } + + private static function hoursWatched(User $user): float + { + return round( + ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1 + ); + } +} diff --git a/app/Services/AgoraTokenService.php b/app/Services/AgoraTokenService.php new file mode 100644 index 0000000..ea91784 --- /dev/null +++ b/app/Services/AgoraTokenService.php @@ -0,0 +1,75 @@ + $expireTimestamp, + self::PRIVILEGE_PUBLISH_AUDIO_STREAM => $expireTimestamp, + self::PRIVILEGE_PUBLISH_VIDEO_STREAM => 0, + self::PRIVILEGE_PUBLISH_DATA_STREAM => $expireTimestamp, + ]; + + // Pack message + $message = self::packUint16(1); // version: 1 (AccessToken) + $message .= self::packUint32($currentTimestamp); + $message .= self::packUint32($nonce); + $message .= self::packString($channelName); + $message .= self::packUint32($uid); + $message .= self::packPrivileges($privileges); + + // HMAC-SHA256 signature + $signature = hash_hmac('sha256', $appId . $currentTimestamp . $nonce . $channelName . $uid . self::packPrivileges($privileges), $appCertificate, true); + + $content = self::packString($signature) . $message; + + return self::VERSION . $appId . base64_encode($content); + } + + private static function packUint16(int $v): string + { + return pack('n', $v); + } + + private static function packUint32(int $v): string + { + return pack('N', $v); + } + + private static function packString(string $v): string + { + return pack('n', strlen($v)) . $v; + } + + private static function packPrivileges(array $privileges): string + { + ksort($privileges); + $packed = pack('n', count($privileges)); + foreach ($privileges as $key => $value) { + $packed .= pack('n', $key) . pack('N', $value); + } + return $packed; + } +} diff --git a/app/Services/AniListService.php b/app/Services/AniListService.php new file mode 100644 index 0000000..d4b1a28 --- /dev/null +++ b/app/Services/AniListService.php @@ -0,0 +1,152 @@ +withHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json']) + ->post(self::ENDPOINT, ['query' => $gql, 'variables' => $variables]); + + if (!$res->ok()) return null; + if (!empty($res->json('errors'))) return null; + + return $res->json('data.Media'); + } catch (\Throwable $e) { + Log::debug('AniList query failed', ['err' => $e->getMessage()]); + return null; + } + } + + public function fetchByMalId(int $malId): ?array + { + return Cache::remember("anilist_mal_{$malId}", self::CACHE_TTL, function () use ($malId) { + return $this->query( + ['malId' => $malId], + 'query($malId:Int){Media(idMal:$malId,type:ANIME){coverImage{extraLarge}bannerImage}}' + ); + }); + } + + public function fetchByTitle(string $title): ?array + { + return Cache::remember('anilist_title_' . md5($title), self::CACHE_TTL, function () use ($title) { + return $this->query( + ['search' => $title], + 'query($search:String){Media(search:$search,type:ANIME){coverImage{extraLarge}bannerImage}}' + ); + }); + } + + /** + * Resmi indir, yeniden boyutlandır, WebP olarak storage'a kaydet. + * Başarılıysa storage-relative yolu döner (örn. anime/covers/123.webp). + */ + /** + * Resmi indir, max genişliğe orantılı küçült (asla büyütme), WebP kaydet. + * Orijinalden küçükse olduğu gibi bırakır. + */ + private function downloadAndResize(string $url, string $storagePath, int $maxW): ?string + { + try { + $response = Http::timeout(20)->withHeaders([ + 'User-Agent' => 'Mozilla/5.0', + 'Referer' => 'https://anilist.co/', + ])->get($url); + + if (!$response->ok()) return null; + + $raw = $response->body(); + $src = @imagecreatefromstring($raw); + if (!$src) return null; + + $srcW = imagesx($src); + $srcH = imagesy($src); + + if ($srcW > $maxW) { + // Orantılı küçült + $newW = $maxW; + $newH = (int) round($srcH * ($maxW / $srcW)); + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH); + imagedestroy($src); + } else { + // Zaten küçük — olduğu gibi kullan + $dst = $src; + } + + $absPath = storage_path('app/public/' . $storagePath); + @mkdir(dirname($absPath), 0755, true); + + $ok = imagewebp($dst, $absPath, self::WEBP_QUALITY); + imagedestroy($dst); + + return $ok ? $storagePath : null; + } catch (\Throwable $e) { + Log::debug('AniList image download failed', ['url' => $url, 'err' => $e->getMessage()]); + return null; + } + } + + /** + * Anime'nin boş kapak/banner alanlarını AniList'ten doldur. + * Resimleri indirir, boyutlandırır, WebP olarak storage'a kaydeder. + * Dolu alanların üzerine yazmaz. + */ + public function fillImages(Anime $anime): bool + { + $needCover = empty($anime->cover_image); + $needBanner = empty($anime->banner_image); + if (!$needCover && !$needBanner) return false; + + $data = null; + if ($anime->mal_id) { + $data = $this->fetchByMalId((int) $anime->mal_id); + } + if (!$data) { + $data = $this->fetchByTitle($anime->title); + } + if (!$data) return false; + + $updates = []; + + if ($needCover && !empty($data['coverImage']['extraLarge'])) { + $path = $this->downloadAndResize( + $data['coverImage']['extraLarge'], + "anime/covers/{$anime->id}.webp", + self::COVER_MAX_W + ); + if ($path) $updates['cover_image'] = $path; + } + + if ($needBanner && !empty($data['bannerImage'])) { + $path = $this->downloadAndResize( + $data['bannerImage'], + "anime/banners/{$anime->id}.webp", + self::BANNER_MAX_W + ); + if ($path) $updates['banner_image'] = $path; + } + + if (empty($updates)) return false; + + $anime->update($updates); + return true; + } +} diff --git a/app/Services/AniSkipService.php b/app/Services/AniSkipService.php new file mode 100644 index 0000000..b7ed727 --- /dev/null +++ b/app/Services/AniSkipService.php @@ -0,0 +1,58 @@ +get($url); + + if (!$res->ok() || empty($res->json('results'))) { + Cache::put($key, null, self::CACHE_MISS); + return null; + } + + $result = []; + foreach ($res->json('results') as $item) { + $type = $item['skip_type'] ?? null; + $interval = $item['interval'] ?? null; + if (!$type || !$interval) continue; + $result[$type] = [ + 'start' => round((float)($interval['start_time'] ?? $interval['startTime'] ?? 0), 2), + 'end' => round((float)($interval['end_time'] ?? $interval['endTime'] ?? 0), 2), + ]; + } + + $data = empty($result) ? null : $result; + Cache::put($key, $data, $data ? self::CACHE_HIT : self::CACHE_MISS); + return $data; + + } catch (\Throwable) { + return null; + } + } + + public function searchByTitle(string $title, ?string $titleEn = null, ?string $titleJp = null): ?string + { + $jikan = new JikanService(); + return $jikan->searchMalId($title, $titleEn, $titleJp); + } +} diff --git a/app/Services/BunnyCdnSigner.php b/app/Services/BunnyCdnSigner.php new file mode 100644 index 0000000..9866765 --- /dev/null +++ b/app/Services/BunnyCdnSigner.php @@ -0,0 +1,62 @@ + $zone, 'apiKey' => $apiKey, 'pullUrl' => $pullUrl]; + } + + /** + * Pull URL'den dosya yolunu çıkar, CDN'den sil. + * Altyazı ve MP4 gibi tekil dosyalar için. + */ + public static function deleteFile(?string $url): void + { + if (!$url) return; + $creds = self::creds(); + if (!$creds) return; + + if (!str_starts_with($url, $creds['pullUrl'])) return; + $path = ltrim(substr($url, strlen($creds['pullUrl'])), '/'); + if (!$path) return; + + self::delete($creds, $path); + } + + /** + * Video URL'sindeki anime klasörünü (anime_XXXXX/) tamamen sil. + * Anime silindiğinde tüm sezon/bölüm dosyaları tek seferde temizlenir. + */ + public static function deleteAnimeFolder(?string $anyVideoUrl): void + { + if (!$anyVideoUrl) return; + $creds = self::creds(); + if (!$creds) return; + + if (!str_starts_with($anyVideoUrl, $creds['pullUrl'])) return; + $path = ltrim(substr($anyVideoUrl, strlen($creds['pullUrl'])), '/'); + $folder = explode('/', $path)[0] ?? ''; + if (!$folder) return; + + // Trailing slash = klasör silme + self::delete($creds, $folder . '/'); + } + + private static function delete(array $creds, string $remotePath): void + { + $url = "https://storage.bunnycdn.com/{$creds['zone']}/{$remotePath}"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => ["AccessKey: {$creds['apiKey']}"], + ]); + curl_exec($ch); + curl_close($ch); + } +} diff --git a/app/Services/DeepSeekService.php b/app/Services/DeepSeekService.php new file mode 100644 index 0000000..a04f041 --- /dev/null +++ b/app/Services/DeepSeekService.php @@ -0,0 +1,636 @@ +apiKey = Setting::get('deepseek_api_key', ''); + } + + public function isConfigured(): bool + { + return !empty($this->apiKey); + } + + /** + * Anime için Türkçe özet/açıklama üret. + */ + public function generateAnimeDescription(string $title, string $titleJp = '', string $genres = ''): ?string + { + $prompt = "Sen bir anime veritabanı editörüsün. Aşağıdaki anime için Türkçe, akıcı ve bilgilendirici bir özet/açıklama yaz (3-5 cümle, 120-220 kelime arası). Spoiler verme, merak uyandır.\n\n" + . "Anime adı: {$title}" . ($titleJp ? " ({$titleJp})" : '') . "\n" + . ($genres ? "Türler: {$genres}\n" : '') + . "\nSadece açıklama metnini yaz, başka hiçbir şey ekleme."; + + return $this->call($prompt); + } + + /** + * Bölüm için Türkçe açıklama üret. + */ + public function generateEpisodeDescription(string $animeTitle, int $episodeNumber, string $episodeTitle = ''): ?string + { + $prompt = "Sen bir anime veritabanı editörüsün. Aşağıdaki anime bölümü için kısa, akıcı ve spoiler içermeyen Türkçe bir açıklama yaz (2-4 cümle, 80-160 kelime arası).\n\n" + . "Anime: {$animeTitle}\n" + . "Bölüm: {$episodeNumber}. Bölüm" . ($episodeTitle ? " — {$episodeTitle}" : '') . "\n\n" + . "Sadece açıklama metnini yaz, başka hiçbir şey ekleme."; + + return $this->call($prompt); + } + + /** + * Anime için tüm meta verileri JSON olarak döndür. + * Dönen alanlar: description, release_year, studio, type, status, rating, title_en, title_jp, genres[] + */ + public function generateAnimeMeta(string $title, string $titleJp = ''): ?array + { + $prompt = <<callJson($prompt); + return $raw; + } + + public function checkSpoiler(string $text): ?array + { + $result = $this->moderateComment($text); + return ['is_spoiler' => $result['is_spoiler'], 'score' => $result['spoiler_score']]; + } + + /** + * Yorum moderasyonu: spoiler + küfür/hakaret kontrolü. + * Döner: ['is_spoiler'=>bool, 'spoiler_score'=>int, 'is_rude'=>bool, 'rude_score'=>int] + */ + public function moderateComment(string $text): array + { + $default = ['is_spoiler' => false, 'spoiler_score' => 0, 'is_rude' => false, 'rude_score' => 0]; + + $prompt = "Aşağıdaki metin bir anime platformuna yazılmış kullanıcı yorumudur. İki şeyi kontrol et:\n" + . "1) Anime bölümüne ait SPOILER içeriyor mu? (olay örgüsü açıklama, karakter ölümü, sürpriz sahne ifşası vb.)\n" + . "2) KABA/HAKARET içeriyor mu? (küfür, nefret söylemi, ağır hakaret, cinsel içerik)\n\n" + . "Sadece JSON döndür:\n" + . "{\"is_spoiler\": false, \"spoiler_score\": 10, \"is_rude\": false, \"rude_score\": 5}\n" + . "score değerleri 0-100 arası olasılık.\n\n" + . "Metin: " . mb_substr($text, 0, 400); + + $raw = $this->callJson($prompt, 80); + if (!$raw) return $default; + + return [ + 'is_spoiler' => (bool)($raw['is_spoiler'] ?? false), + 'spoiler_score' => (int)($raw['spoiler_score'] ?? $raw['score'] ?? 0), + 'is_rude' => (bool)($raw['is_rude'] ?? false), + 'rude_score' => (int)($raw['rude_score'] ?? 0), + ]; + } + + public string $lastError = ''; + + private function callJson(string $prompt, int $maxTokens = 600): ?array + { + if (!$this->isConfigured()) { + $this->lastError = 'API anahtarı ayarlanmamış'; + return null; + } + + try { + $response = Http::withToken($this->apiKey) + ->timeout(90) + ->post('https://api.deepseek.com/chat/completions', [ + 'model' => 'deepseek-chat', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'max_tokens' => $maxTokens, + 'temperature' => 0.3, + 'response_format' => ['type' => 'json_object'], + ]); + + if (!$response->successful()) { + $this->lastError = 'HTTP ' . $response->status() . ': ' . $response->json('error.message', $response->body()); + \Log::error('DeepSeek API hatası', ['status' => $response->status(), 'body' => $response->body()]); + return null; + } + + $content = trim($response->json('choices.0.message.content', '')); + if (!$content) { + $this->lastError = 'API boş yanıt döndürdü'; + return null; + } + + $content = preg_replace('/^```json\s*/i', '', $content); + $content = preg_replace('/\s*```$/i', '', $content); + + $data = json_decode($content, true); + if (!is_array($data)) { + $this->lastError = 'JSON parse hatası: ' . substr($content, 0, 200); + return null; + } + return $data; + } catch (\Exception $e) { + $this->lastError = $e->getMessage(); + \Log::error('DeepSeek exception', ['message' => $e->getMessage()]); + return null; + } + } + + // ── Frontend AI methods ────────────────────────────────────────────────── + + /** + * Anime kataloğunu AI context string olarak döndür (1 saat önbellek). + */ + public function getAnimeContext(): string + { + return \Illuminate\Support\Facades\Cache::remember('ai_anime_context', 3600, function () { + $animes = \App\Models\Anime::where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'type', 'status', 'rating', 'release_year']); + + return $animes->map(function ($a) { + $genres = $a->genres->pluck('name')->join(', '); + $type = $a->type === 'movie' ? 'Film' : 'Dizi'; + return "ID:{$a->id}|{$a->title}|{$type}|{$a->release_year}|{$a->rating}" + . ($genres ? "|{$genres}" : ''); + })->join("\n"); + }); + } + + /** + * Çok turlu sohbet (sistem mesajı + anime kataloğu ile). + * $messages = [['role'=>'user','content'=>'...'], ...] + */ + public function chat(array $messages, string $animeContext = ''): ?string + { + $sys = "Sen Animexe'nin AI anime asistanısın. Animexe, Türkçe altyazılı/dublajlı ücretsiz anime izleme platformudur (animexe.com).\n\n"; + + $sys .= "== PLATFORM BİLGİLERİ (kullanıcı sorarsa bunları kullan) ==\n" + . "- Kayıt: Ücretsiz, e-posta ile. Kayıt olmadan bazı içerikler kısıtlı.\n" + . "- Premium üyelik: Aylık ücretli. Avantajları: reklamsız izleme, 1080p HD, erken bölüm erişimi.\n" + . "- Altyazı/Dublaj: Türkçe altyazı ve Türkçe dublaj seçenekleri mevcuttur. Player'da seçilebilir.\n" + . "- Takip/Favori: Anime sayfasında kalp veya 'Takip' butonuna tıkla. Yeni bölüm bildirimi gelir.\n" + . "- İzleme geçmişi: Otomatik kaydedilir. Profil > Geçmiş kısmından görebilirsin.\n" + . "- Arama: Üst menüdeki arama kutusuna anime adını yaz.\n" + . "- Anime isteği: 'Anime İste' sayfasından eksik animeleri talep edebilirsin.\n" + . "- Mobil: Tarayıcıdan tam destek. Android uygulaması da mevcut.\n" + . "- Dil seçimi: Player'da ses ve altyazı dili değiştirilebilir.\n" + . "- Yorumlar: Her bölümün altında yorum yapılabilir, spoiler işaretlenebilir.\n\n"; + + if ($animeContext) { + $sys .= "== PLATFORM KATALOĞU (ID|Başlık|Tip|Yıl|Puan|Türler) ==\n{$animeContext}\n\n"; + } + + $sys .= "== KURALLAR ==\n" + . "- Türkçe, samimi, kısa ve net cevap ver. Emoji kullanabilirsin.\n" + . "- Sadece platformdaki animeleri öner (katalogdan ID'si olan).\n" + . "- Spoiler verme. Merak uyandır.\n" + . "- Anime önerirken cevabının en sonuna şu formatı ekle (başka yere koyma): [SUGGEST:id1,id2,id3]\n" + . " Örnek: 'Attack on Titan harika! [SUGGEST:42]' — max 5 anime ID.\n" + . "- Eğer anime önermiyorsan [SUGGEST:...] satırını HİÇ EKLEME.\n" + . "- Site hakkında soruları yukarıdaki platform bilgilerini kullanarak cevapla.\n"; + + $apiMessages = array_merge( + [['role' => 'system', 'content' => $sys]], + array_slice($messages, -12) + ); + + return $this->callMessages($apiMessages, 700); + } + + /** + * Kullanıcı tercihlerine göre 6 anime öner. + * Döner: [['id'=>1,'reason'=>'...'], ...] + */ + public function recommend(string $preferences, array $animes): ?array + { + $list = implode("\n", array_map(function ($a) { + $genres = isset($a['genres']) ? implode(', ', array_column($a['genres'], 'name')) : ''; + return "ID:{$a['id']}|{$a['title']}|" . ($a['type'] === 'movie' ? 'Film' : 'Dizi') + . "|{$a['release_year']}|{$a['rating']}" . ($genres ? "|{$genres}" : ''); + }, array_slice($animes, 0, 250))); + + $prompt = "Anime öneri sistemi: Kullanıcı tercihlerine göre listeden EN İYİ 6 animeyi seç.\n\n" + . "Tercihler:\n{$preferences}\n\n" + . "Animeler:\n{$list}\n\n" + . "Yanıt: {\"recommendations\":[{\"id\":1,\"reason\":\"Kısa Türkçe neden (max 12 kelime)\"}]}\n" + . "SADECE JSON."; + + $result = $this->callJson($prompt, 500); + if (!is_array($result)) return null; + if (isset($result['recommendations']) && is_array($result['recommendations'])) { + return $result['recommendations']; + } + if (isset($result[0]['id'])) return $result; + return null; + } + + /** + * Doğal dil sorgusu ile anime ara. + * Döner: [id1, id2, ...] + */ + public function naturalSearch(string $query, array $animes): ?array + { + $list = implode("\n", array_map(function ($a) { + $genres = isset($a['genres']) ? implode(', ', array_column($a['genres'], 'name')) : ''; + return "ID:{$a['id']}|{$a['title']}" . ($genres ? "|{$genres}" : ''); + }, array_slice($animes, 0, 300))); + + $prompt = "Kullanıcı sorgusu: \"{$query}\"\n\nAnimeler:\n{$list}\n\n" + . "En uygun max 12 animeyi bul: {\"ids\":[1,5,12]}\nSADECE JSON."; + + $result = $this->callJson($prompt, 150); + if (!is_array($result)) return null; + if (isset($result['ids']) && is_array($result['ids'])) return array_map('intval', $result['ids']); + return null; + } + + /** + * Bölüm hakkında spoilersız AI analizi. + */ + public function episodeInfo(string $animeTitle, int $episodeNumber, string $episodeTitle = '', string $description = ''): ?string + { + $prompt = "Sen bir anime uzmanısın. Aşağıdaki bölüm hakkında Türkçe, kısa ve ilgi çekici bir analiz yaz (3-4 cümle). " + . "Spoiler içerme. Bölümün atmosferini, önemini ve izleyiciyi neden heyecanlandırabileceğini anlat.\n\n" + . "Anime: {$animeTitle}\n" + . "Bölüm: {$episodeNumber}." . ($episodeTitle ? " — {$episodeTitle}" : '') . "\n" + . ($description ? "Açıklama: {$description}\n" : '') + . "\nSadece analiz metnini yaz."; + + return $this->call($prompt, 350); + } + + // ── Blog Generation ────────────────────────────────────────────────────── + + /** + * Bir anime için SEO blog yazısı üret. + * Döner: ['title','slug','excerpt','content','focus_keyword','meta_description','faq','linked_slugs'] + */ + public function generateBlogPost(\App\Models\Anime $anime, array $relatedAnimes = []): ?array + { + $genreList = $anime->genres->pluck('name')->join(', '); + $type = $anime->type === 'movie' ? 'anime film' : 'anime dizi'; + $year = $anime->release_year ?? ''; + $status = match($anime->status ?? '') { + 'ongoing' => 'devam ediyor', + 'completed' => 'tamamlandı', + 'upcoming' => 'yakında çıkacak', + default => '', + }; + + $relatedList = ''; + if (!empty($relatedAnimes)) { + $relatedList = "\nİlgili animeler (içerik içinde bunlara link ver, format: [LINK:slug]Anime Adı[/LINK]):\n"; + foreach (array_slice($relatedAnimes, 0, 5) as $r) { + $relatedList .= "- {$r['slug']}: {$r['title']}\n"; + } + } + + $prompt = <<title} +- Tür: {$type} +- Yıl: {$year} +- Türler: {$genreList} +- Durum: {$status} +- Açıklama: {$anime->description} +{$relatedList} + +Blog yazısı gereksinimleri: +1. 400-600 kelime, sade ve akıcı Türkçe +2. HTML formatında yaz:

,

,

    ,
  • , etiketleri kullan +3. Yapı: Giriş (1-2 paragraf) → Ana içerik (2 H2 bölümü) → FAQ (2 soru-cevap,

    ...

    ...

    ) +4. {$anime->title} anahtar kelimesini doğal şekilde 3-5 kez kullan +5. İlgili animelere [LINK:slug]Anime Adı[/LINK] formatında link ekle (varsa, max 2) +6. Spoiler verme, merak uyandır +7. Sonunda kısa bir CTA ekle + +Yanıt JSON formatında: +{{ + "title": "SEO başlığı (50-60 karakter, anime adını içermeli)", + "excerpt": "Meta description (150-160 karakter)", + "focus_keyword": "Ana anahtar kelime", + "meta_description": "SEO meta açıklaması (150-160 karakter)", + "content": "Tam HTML blog içeriği", + "faq": [ + {{"q": "Soru", "a": "Cevap"}}, + {{"q": "Soru", "a": "Cevap"}}, + {{"q": "Soru", "a": "Cevap"}} + ], + "linked_slugs": ["slug1", "slug2"] +}} +SADECE geçerli JSON döndür. +PROMPT; + + return $this->callJson($prompt, 4096); + } + + // ── SEO AI Methods ─────────────────────────────────────────────────────── + + /** + * SEO danışman sohbeti — sitenin kontekstini bilen uzman + */ + public function seoChat(array $messages, array $siteContext = []): ?string + { + $stats = $siteContext; + $sys = << 'system', 'content' => $sys]], + array_slice($messages, -16) + ); + + return $this->callMessages($apiMessages, 800); + } + + /** + * Anime için AI destekli SEO başlığı + meta açıklama üret + */ + public function generateAnimeSeoMeta(\App\Models\Anime $anime): ?array + { + $genres = $anime->genres?->pluck('name')->join(', ') ?? ''; + $type = $anime->type === 'movie' ? 'anime film' : ($anime->type === 'ova' ? 'OVA' : 'anime dizi'); + $year = $anime->release_year ?? ''; + $desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 300) : ''; + + $prompt = <<title} +- Tür: {$type} +- Yıl: {$year} +- Kategoriler: {$genres} +- Açıklama: {$desc} + +Kurallar: +- SEO Başlığı: 50-65 karakter, anahtar kelimeyi başa al, duygusal tetikleyici ekle, "Türkçe" kelimesi kullan +- Meta Açıklama: 145-158 karakter, aksiyon çağrısı içersin, "ücretsiz", "HD" gibi değer önerileri ekle +- Anahtar Kelimeler: 3-5 adet, virgülle ayrılmış, Türkçe arama trendlerine uygun +- Kullanıcı niyeti: anime izlemek isteyen Türkçe konuşan kullanıcılar + +JSON formatında yanıt ver: +{ + "seo_title": "...", + "seo_meta_desc": "...", + "seo_keywords": "...", + "primary_keyword": "...", + "search_intent": "transactional|informational|navigational" +} +SADECE JSON. +PROMPT; + + return $this->callJson($prompt, 400); + } + + /** + * Belirli bir konu için anahtar kelime önerileri + */ + public function suggestKeywords(string $topic, string $niche = 'anime'): ?array + { + $prompt = <<callJson($prompt, 1200); + } + + /** + * URL'nin SEO sorunlarını AI ile analiz et + */ + public function analyzePageSeo(string $url, string $title, string $description, string $content): ?array + { + $contentSnippet = mb_substr(strip_tags($content), 0, 500); + + $prompt = <<callJson($prompt, 800); + } + + /** + * Anime için FAQ Schema (JSON-LD) üret + */ + public function generateFaqSchema(\App\Models\Anime $anime): ?array + { + $type = $anime->type === 'movie' ? 'film' : 'dizi'; + $desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 200) : $anime->title; + $genres = $anime->genres?->pluck('name')->join(', ') ?? ''; + + $prompt = <<title} ({$type}) +Kategoriler: {$genres} +Açıklama: {$desc} + +Kurallar: +- Kullanıcıların gerçekten sorduğu sorular ("nerede izlenir", "kaç bölüm", "türkçe var mı" gibi) +- Cevaplar 1-3 cümle, net ve bilgilendirici +- Türkçe +- Animexe platformuna yönlendiren cevaplar (animexe.com'da izleyebilirsiniz) + +JSON formatı: +{ + "faqs": [ + {"question": "...", "answer": "..."}, + ... + ] +} +SADECE JSON. +PROMPT; + + return $this->callJson($prompt, 600); + } + + /** + * Site için genel içerik stratejisi öner + */ + public function generateContentStrategy(array $siteStats, array $weakKeywords = []): ?string + { + $kwList = !empty($weakKeywords) ? implode(', ', array_slice($weakKeywords, 0, 10)) : 'genel anime'; + $score = $siteStats['seo_score'] ?? 0; + $total = $siteStats['anime_count'] ?? 0; + $covered = $siteStats['seo_covered'] ?? 0; + + $prompt = <<call($prompt, 1200); + } + + /** + * Robots.txt için AI önerisi + */ + public function generateRobotsTxt(string $domain, array $paths = []): ?string + { + $pathList = !empty($paths) ? implode(', ', $paths) : '/admin, /api, /storage, /profile'; + + $prompt = "Sen bir teknik SEO uzmanısın. {$domain} domaini için optimal robots.txt içeriği oluştur. " + . "Platform bir anime izleme sitesi. Korunacak dizinler: {$pathList}. " + . "Sadece robots.txt içeriğini döndür, açıklama ekleme."; + + return $this->call($prompt, 300); + } + + /** + * Keşfet sayfası için anime başına kısa, çekici hook metni üret (2 cümle, spoiler yok). + */ + public function generateDiscoveryHook(\App\Models\Anime $anime): ?string + { + $genres = $anime->relationLoaded('genres') + ? $anime->genres->pluck('name')->join(', ') + : ''; + + $info = $anime->title; + if ($anime->release_year) $info .= " ({$anime->release_year})"; + if ($genres) $info .= ", {$genres}"; + if ($anime->description) $info .= ". " . \Illuminate\Support\Str::limit(strip_tags($anime->description), 180); + + $prompt = "Sen bir anime tanıtım yazarısın. Aşağıdaki anime için 1-2 cümlelik, merak uyandırıcı, spoiler içermeyen Türkçe bir tanıtım yaz. Emoji kullanma. Sadece tanıtım metnini yaz.\n\n{$info}"; + + return $this->call($prompt, 80); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private function callMessages(array $messages, int $maxTokens = 400): ?string + { + if (!$this->isConfigured()) return null; + try { + $r = Http::withToken($this->apiKey)->timeout(35) + ->post('https://api.deepseek.com/chat/completions', [ + 'model' => 'deepseek-chat', 'messages' => $messages, + 'max_tokens' => $maxTokens, 'temperature' => 0.75, + ]); + if (!$r->successful()) return null; + return trim($r->json('choices.0.message.content', '')) ?: null; + } catch (\Exception $e) { return null; } + } + + private function call(string $prompt, int $maxTokens = 400): ?string + { + if (!$this->isConfigured()) { + return null; + } + + try { + $response = Http::withToken($this->apiKey) + ->timeout(30) + ->post('https://api.deepseek.com/chat/completions', [ + 'model' => 'deepseek-chat', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'max_tokens' => $maxTokens, + 'temperature' => 0.7, + ]); + + if (!$response->successful()) { + return null; + } + + return trim($response->json('choices.0.message.content', '')) ?: null; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/app/Services/FcmService.php b/app/Services/FcmService.php new file mode 100644 index 0000000..685ccb4 --- /dev/null +++ b/app/Services/FcmService.php @@ -0,0 +1,85 @@ +projectId = config('services.firebase.project_id', 'animexeapp'); + $this->serverKey = config('services.firebase.server_key'); + } + + /** + * Send push notification to a single FCM token. + */ + public function sendToToken(string $token, string $title, string $body, array $data = []): bool + { + if (!$this->serverKey) { + Log::warning('FCM server key not configured'); + return false; + } + + try { + $response = Http::withHeaders([ + 'Authorization' => 'key=' . $this->serverKey, + 'Content-Type' => 'application/json', + ])->post('https://fcm.googleapis.com/fcm/send', [ + 'to' => $token, + 'notification' => [ + 'title' => $title, + 'body' => $body, + 'sound' => 'default', + ], + 'data' => $data, + 'priority' => 'high', + ]); + + return $response->successful(); + } catch (\Throwable $e) { + Log::error('FCM send error: ' . $e->getMessage()); + return false; + } + } + + /** + * Send to multiple tokens (batch). + */ + public function sendToTokens(array $tokens, string $title, string $body, array $data = []): int + { + if (!$this->serverKey || empty($tokens)) return 0; + + $sent = 0; + // FCM supports max 1000 tokens per batch + foreach (array_chunk($tokens, 1000) as $chunk) { + try { + $response = Http::withHeaders([ + 'Authorization' => 'key=' . $this->serverKey, + 'Content-Type' => 'application/json', + ])->post('https://fcm.googleapis.com/fcm/send', [ + 'registration_ids' => $chunk, + 'notification' => [ + 'title' => $title, + 'body' => $body, + 'sound' => 'default', + ], + 'data' => $data, + 'priority' => 'high', + ]); + + if ($response->successful()) { + $sent += count($chunk); + } + } catch (\Throwable $e) { + Log::error('FCM batch send error: ' . $e->getMessage()); + } + } + return $sent; + } +} diff --git a/app/Services/JikanService.php b/app/Services/JikanService.php new file mode 100644 index 0000000..87c6e5c --- /dev/null +++ b/app/Services/JikanService.php @@ -0,0 +1,162 @@ +getSequel($current); + if (!$sequel) break; + $current = $sequel; + + // Jikan rate limit: max 3 req/s — small sleep between calls + usleep(400_000); // 400ms + } + + return $chain; + } + + /** + * Returns the MAL ID of the direct "Sequel" relation, or null. + */ + public function getSequel(string $malId): ?string + { + $key = "jikan_relations_{$malId}"; + $data = Cache::remember($key, self::CACHE_TTL, function () use ($malId) { + $res = Http::timeout(10)->get(self::BASE . "/anime/{$malId}/relations"); + if (!$res->ok()) return null; + return $res->json(); + }); + + if (!$data || empty($data['data'])) return null; + + foreach ($data['data'] as $rel) { + if (strtolower($rel['relation']) === 'sequel') { + foreach ($rel['entry'] as $entry) { + if ($entry['type'] === 'anime') { + return (string) $entry['mal_id']; + } + } + } + } + + return null; + } + + /** + * Search Jikan by title, return best-matching MAL ID or null. + * Tries title_jp first, then title_en, then title. + * $animeType: 'series'|'movie'|'ova'|'ona'|'special' (optional, improves accuracy) + */ + public function searchMalId(string $title, ?string $titleEn = null, ?string $titleJp = null, ?string $animeType = null): ?string + { + $queries = array_values(array_filter(array_unique([$titleJp, $titleEn, $title]))); + foreach ($queries as $i => $q) { + $malId = $this->searchByQuery($q, $animeType); + if ($malId) return $malId; + if ($i < count($queries) - 1) usleep(350_000); + } + return null; + } + + private function searchByQuery(string $query, ?string $animeType = null): ?string + { + // Map our type → Jikan type; try specific first then fallback to any + $jikanType = match ($animeType) { + 'movie' => 'movie', + 'ova' => 'ova', + 'ona' => 'ona', + 'special' => 'special', + default => 'tv', + }; + + // Try with specific type, then without type restriction (catches edge cases) + $typesToTry = array_unique([$jikanType, null]); + + foreach ($typesToTry as $type) { + $cacheKey = 'jikan_s2_' . md5($query . '_' . ($type ?? 'any')); + $data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($query, $type) { + $params = ['q' => $query, 'limit' => 8, 'sfw' => false]; + if ($type) $params['type'] = $type; + $res = Http::timeout(10)->get(self::BASE . '/anime', $params); + if (!$res->ok()) return null; + return $res->json('data'); + }); + + if (!empty($data)) { + $best = $this->bestMatch($query, $data); + if ($best) return (string) $best['mal_id']; + } + + if ($type !== null) usleep(300_000); // rate limit between type fallback + } + + return null; + } + + /** + * Pick the result whose title best matches the query via similar_text. + * Falls back to first result if nothing scores > 40%. + */ + private function bestMatch(string $query, array $results): ?array + { + $q = mb_strtolower(trim($query)); + $best = null; + $bestScore = 0; + + foreach ($results as $item) { + $candidates = array_filter([ + $item['title'] ?? null, + $item['title_english'] ?? null, + $item['title_japanese'] ?? null, + ]); + foreach ($candidates as $t) { + similar_text($q, mb_strtolower(trim($t)), $pct); + if ($pct > $bestScore) { + $bestScore = $pct; + $best = $item; + } + } + } + + // If best match is decent or we have no choice, return it + return ($best && $bestScore >= 35) ? $best : ($results[0] ?? null); + } + + /** + * Fetch anime details (title_english, title, episodes count etc.) + */ + public function getAnimeDetails(string $malId): ?array + { + $key = "jikan_anime_{$malId}"; + $data = Cache::remember($key, self::CACHE_TTL, function () use ($malId) { + $res = Http::timeout(10)->get(self::BASE . "/anime/{$malId}"); + if (!$res->ok()) return null; + return $res->json('data'); + }); + + return $data; + } +} diff --git a/app/Services/PremiumFeatures.php b/app/Services/PremiumFeatures.php new file mode 100644 index 0000000..3a65e65 --- /dev/null +++ b/app/Services/PremiumFeatures.php @@ -0,0 +1,235 @@ + [ + 'name' => 'İzleme Listesi Dışa Aktarımı', + 'description' => 'Tüm izleme listeni CSV veya JSON olarak indir', + 'category' => 'İzleme', + 'icon' => 'bi-download', + ], + 'anime_notes' => [ + 'name' => 'Kişisel Anime Notları', + 'description' => 'Her bölüm için sadece sana görünen gizli notlar bırak', + 'category' => 'İzleme', + 'icon' => 'bi-journal-text', + ], + 'stream_history' => [ + 'name' => 'Sınırsız İzleme Geçmişi', + 'description' => 'Tüm geçmiş saklanır; standart üyede 30 kayıt limiti', + 'category' => 'İzleme', + 'icon' => 'bi-clock-history', + ], + + // ── Sosyal / Yorum ──────────────────────────────────────────────────── + 'comment_bg' => [ + 'name' => 'Yorum Arkaplanı Efekti', + 'description' => 'Yorumlarına özel animasyonlu arkaplan ekle', + 'category' => 'Sosyal', + 'icon' => 'bi-fire', + ], + 'extended_comments' => [ + 'name' => 'Uzun Yorum (1000 karakter)', + 'description' => 'Ücretsiz kullanıcıların 2 katı yorum uzunluğu', + 'category' => 'Sosyal', + 'icon' => 'bi-chat-text-fill', + ], + 'comment_gif' => [ + 'name' => 'Yoruma GIF Ekle', + 'description' => 'Yorumlarına Tenor/GIPHY GIFleri ekleyebilirsin', + 'category' => 'Sosyal', + 'icon' => 'bi-filetype-gif', + ], + 'comment_glow' => [ + 'name' => 'Yorum Aura / Parıltı', + 'description' => 'Yorumlarının çevresinde renkli parlayan enerji halkası', + 'category' => 'Sosyal', + 'icon' => 'bi-brightness-high-fill', + ], + 'comment_signature' => [ + 'name' => 'Yorum İmzası', + 'description' => 'Her yorumun altında görünen kişisel imza satırı', + 'category' => 'Sosyal', + 'icon' => 'bi-pen-fill', + ], + + // ── Profil / Kozmetik ───────────────────────────────────────────────── + 'gif_avatar' => [ + 'name' => 'GIF Profil Fotoğrafı', + 'description' => 'Hareketli GIF\'i profil resmi olarak ayarla', + 'category' => 'Profil', + 'icon' => 'bi-image-fill', + ], + 'username_color' => [ + 'name' => 'Renkli Kullanıcı Adı', + 'description' => 'Yorumlarda kullanıcı adın özel renkte görünsün', + 'category' => 'Profil', + 'icon' => 'bi-palette-fill', + ], + 'username_effect' => [ + 'name' => 'Kullanıcı Adı Animasyonu', + 'description' => 'Shimmer, dalga, glitch gibi özel animasyon efektleri', + 'category' => 'Profil', + 'icon' => 'bi-lightning-charge-fill', + ], + 'profile_frame' => [ + 'name' => 'Profil Çerçevesi', + 'description' => 'Avatarının çevresinde animasyonlu çerçeve', + 'category' => 'Profil', + 'icon' => 'bi-circle-fill', + ], + 'profile_badge' => [ + 'name' => 'Özel Rozet/Unvan', + 'description' => 'Kullanıcı adının yanında özel rozet veya unvan', + 'category' => 'Profil', + 'icon' => 'bi-award-fill', + ], + 'profile_bg' => [ + 'name' => 'Animasyonlu Profil Arka Planı', + 'description' => 'Profil sayfanda canlı animasyonlu arka plan', + 'category' => 'Profil', + 'icon' => 'bi-stars', + ], + 'animated_banner' => [ + 'name' => 'Animasyonlu Profil Bannerı', + 'description' => 'Profil bannerın parçacık ve dalga efektiyle canlanır', + 'category' => 'Profil', + 'icon' => 'bi-image-alt', + ], + 'entry_effect' => [ + 'name' => 'Sayfa Giriş Efekti', + 'description' => 'Sayfalara girerken özel giriş animasyonu', + 'category' => 'Profil', + 'icon' => 'bi-play-circle-fill', + ], + 'watch_rank' => [ + 'name' => 'İzleme Rank Rozeti', + 'description' => 'İzleme saatine göre Acemi→Efsane arası özel rank', + 'category' => 'Profil', + 'icon' => 'bi-trophy-fill', + ], + + // ── Hesap ───────────────────────────────────────────────────────────── + 'custom_profile_url' => [ + 'name' => 'Özel Profil URL\'i', + 'description' => 'animexe.com/u/senin-adin gibi kişisel URL', + 'category' => 'Hesap', + 'icon' => 'bi-link-45deg', + ], + 'profile_music' => [ + 'name' => 'Profil Müziği', + 'description' => 'Profil sayfanda bir anime OST çal', + 'category' => 'Hesap', + 'icon' => 'bi-music-note-beamed', + ], + ]; + + // Kategori sıralaması + public const CATEGORIES = ['İzleme', 'Sosyal', 'Profil', 'Hesap']; + + // Yorum aura/parıltı efektleri + public const COMMENT_GLOWS = [ + 'cyan' => ['label' => 'Siyan', 'color' => '#00f5ff'], + 'pink' => ['label' => 'Pembe', 'color' => '#ff2d7d'], + 'gold' => ['label' => 'Altın', 'color' => '#ffd700'], + 'green' => ['label' => 'Yeşil', 'color' => '#00f564'], + 'purple' => ['label' => 'Mor', 'color' => '#b84dff'], + 'fire' => ['label' => 'Alev', 'color' => '#ff6b35'], + ]; + + // Kullanıcı adı animasyon efektleri + public const USERNAME_EFFECTS = [ + 'shimmer' => ['label' => 'Işıltı'], + 'wave' => ['label' => 'Dalga'], + 'pulse' => ['label' => 'Nabız'], + 'glitch' => ['label' => 'Glitch'], + 'bounce' => ['label' => 'Zıplama'], + ]; + + // Sayfa giriş efektleri + public const ENTRY_EFFECTS = [ + 'fade' => ['label' => 'Solma'], + 'slide' => ['label' => 'Kayma'], + 'zoom' => ['label' => 'Yakınlaştırma'], + 'glitch' => ['label' => 'Glitch'], + 'wave' => ['label' => 'Dalga'], + ]; + + // Profil arka plan stilleri + public const PROFILE_BACKGROUNDS = [ + 'fire' => ['label' => 'Alev', 'preview' => '#ff6b35,#ff2d7d'], + 'galaxy' => ['label' => 'Galaksi', 'preview' => '#0d0520,#050510'], + 'aurora' => ['label' => 'Aurora', 'preview' => '#040e0e,#071a1a'], + 'ice' => ['label' => 'Buz', 'preview' => '#040d11,#071420'], + 'sakura' => ['label' => 'Sakura', 'preview' => '#ff9ec4,#ffd6e7'], + 'neon' => ['label' => 'Neon', 'preview' => '#00f5ff,#b84dff'], + 'stars' => ['label' => 'Yıldızlar', 'preview' => '#0a0a2e,#7c3aed'], + ]; + + // Yorum arkaplan stilleri + public const COMMENT_BACKGROUNDS = [ + 'fire' => ['label' => 'Alev', 'preview' => '#ff6b35,#ff2d7d'], + 'aurora' => ['label' => 'Aurora', 'preview' => '#00f5b4,#00f5ff'], + 'stars' => ['label' => 'Yıldızlar', 'preview' => '#0a0a2e,#7c3aed'], + 'sakura' => ['label' => 'Sakura', 'preview' => '#ff9ec4,#ffd6e7'], + 'neon' => ['label' => 'Neon', 'preview' => '#00f5ff,#b84dff'], + 'galaxy' => ['label' => 'Galaksi', 'preview' => '#0d0d2e,#4a1d96'], + 'ice' => ['label' => 'Buz', 'preview' => '#a8edff,#e0f7ff'], + ]; + + // Profil çerçeve stilleri + public const PROFILE_FRAMES = [ + 'neon' => ['label' => 'Neon', 'color' => '#00f5ff'], + 'fire' => ['label' => 'Alev', 'color' => '#ff6b35'], + 'sakura' => ['label' => 'Sakura', 'color' => '#ff9ec4'], + 'galaxy' => ['label' => 'Galaksi', 'color' => '#b84dff'], + 'gold' => ['label' => 'Altın', 'color' => '#ffd700'], + 'ice' => ['label' => 'Buz', 'color' => '#a8edff'], + 'blood' => ['label' => 'Kan', 'color' => '#dc143c'], + 'mint' => ['label' => 'Mint', 'color' => '#00ff7f'], + 'rainbow' => ['label' => 'Gökkuşağı', 'color' => '#ff0000'], + 'ocean' => ['label' => 'Okyanus', 'color' => '#006fbf'], + 'poison' => ['label' => 'Zehir', 'color' => '#9400d3'], + ]; + + // Kullanıcı adı renk presetleri + public const USERNAME_COLORS = [ + 'fire' => ['label' => 'Alev', 'css' => 'linear-gradient(90deg,#ff6b35,#ff2d7d)'], + 'aurora' => ['label' => 'Aurora', 'css' => 'linear-gradient(90deg,#00f5b4,#00f5ff)'], + 'sakura' => ['label' => 'Sakura', 'css' => 'linear-gradient(90deg,#ff9ec4,#ff2d7d)'], + 'neon' => ['label' => 'Neon', 'css' => 'linear-gradient(90deg,#00f5ff,#b84dff)'], + 'galaxy' => ['label' => 'Galaksi', 'css' => 'linear-gradient(90deg,#7c3aed,#b84dff)'], + 'gold' => ['label' => 'Altın', 'css' => 'linear-gradient(90deg,#ffd700,#ff8c00)'], + 'ice' => ['label' => 'Buz', 'css' => 'linear-gradient(90deg,#a8edff,#60cfff)'], + 'blood' => ['label' => 'Kan', 'css' => 'linear-gradient(90deg,#8b0000,#dc143c)'], + 'sunset' => ['label' => 'Gün Batımı', 'css' => 'linear-gradient(90deg,#ff6b00,#ff0080)'], + 'ocean' => ['label' => 'Okyanus', 'css' => 'linear-gradient(90deg,#006fbf,#00d2ff)'], + 'poison' => ['label' => 'Zehir', 'css' => 'linear-gradient(90deg,#6b0ac9,#c300ff)'], + 'silver' => ['label' => 'Gümüş', 'css' => 'linear-gradient(90deg,#9ca3af,#e5e7eb)'], + 'rainbow' => ['label' => 'Gökkuşağı', 'css' => 'linear-gradient(90deg,#ff0000,#ff8c00,#ffd700,#00c800,#0088ff,#8b00ff)'], + ]; + + /** Feature key'e göre metadata döner, yoksa null */ + public static function get(string $key): ?array + { + return self::ALL[$key] ?? null; + } + + /** Kategoriye göre gruplanmış feature listesi */ + public static function grouped(): array + { + $groups = []; + foreach (self::CATEGORIES as $cat) { + $groups[$cat] = []; + } + foreach (self::ALL as $key => $meta) { + $groups[$meta['category']][$key] = $meta; + } + return array_filter($groups); + } +} diff --git a/app/Support/ActivityLogger.php b/app/Support/ActivityLogger.php new file mode 100644 index 0000000..b26100c --- /dev/null +++ b/app/Support/ActivityLogger.php @@ -0,0 +1,77 @@ +ip(); + $ua = $req->userAgent() ?? ''; + $isBot = (bool) $req->attributes->get('is_bot', false); + $geo = cache()->remember("geo_{$ip}", 3600, fn() => self::geoIp($ip)); + + UserActivityLog::create([ + 'user_id' => $userId ?? auth()->id(), + 'session_id' => session()->getId(), + 'action' => $action, + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'ip' => $ip, + 'country' => $geo['country'] ?? null, + 'city' => $geo['city'] ?? null, + 'device' => self::device($ua), + 'browser' => self::browser($ua), + 'user_agent' => mb_substr($ua, 0, 500), + 'is_bot' => $isBot, + 'meta' => $meta, + 'created_at' => now(), + ]); + } catch (\Throwable) { + // never break the app + } + } + + private static function geoIp(string $ip): array + { + try { + if (in_array($ip, ['127.0.0.1', '::1']) || str_starts_with($ip, '192.168.') || str_starts_with($ip, '10.')) { + return ['country' => 'Local', 'city' => null]; + } + $r = \Illuminate\Support\Facades\Http::timeout(2)->get("http://ip-api.com/json/{$ip}?fields=country,city,status"); + $d = $r->json(); + return ($d['status'] ?? '') === 'success' ? ['country' => $d['country'], 'city' => $d['city']] : []; + } catch (\Throwable) { + return []; + } + } + + private static function device(string $ua): string + { + $ua = strtolower($ua); + if (str_contains($ua, 'mobile') || str_contains($ua, 'android') || str_contains($ua, 'iphone')) return 'mobile'; + if (str_contains($ua, 'tablet') || str_contains($ua, 'ipad')) return 'tablet'; + return 'desktop'; + } + + private static function browser(string $ua): string + { + if (str_contains($ua, 'Chrome')) return 'Chrome'; + if (str_contains($ua, 'Firefox')) return 'Firefox'; + if (str_contains($ua, 'Safari')) return 'Safari'; + if (str_contains($ua, 'Edge')) return 'Edge'; + if (str_contains($ua, 'Opera')) return 'Opera'; + return 'Other'; + } +} diff --git a/app/Support/ImageOptimizer.php b/app/Support/ImageOptimizer.php new file mode 100644 index 0000000..ab2b42e --- /dev/null +++ b/app/Support/ImageOptimizer.php @@ -0,0 +1,90 @@ + ['w' => 600, 'h' => 900, 'quality' => 92], + 'banner' => ['w' => 1920, 'h' => 1080, 'quality' => 90], + 'thumbnail' => ['w' => 854, 'h' => 480, 'quality' => 88], + 'site_banner'=> ['w' => 1920, 'h' => 600, 'quality' => 90], + 'avatar' => ['w' => 400, 'h' => 400, 'quality' => 92], + 'default' => ['w' => 1920, 'h' => 1920, 'quality' => 92], + ]; + + public static function store(UploadedFile $file, string $folder, string $preset = 'default'): string + { + if (!extension_loaded('gd')) { + return $file->store($folder, 'public'); + } + + try { + $cfg = self::PRESETS[$preset] ?? self::PRESETS['default']; + $path = $file->getRealPath(); + $mime = mime_content_type($path); + + $src = match (true) { + str_contains($mime, 'jpeg'), str_contains($mime, 'jpg') => @imagecreatefromjpeg($path), + str_contains($mime, 'png') => @imagecreatefrompng($path), + str_contains($mime, 'webp') => @imagecreatefromwebp($path), + str_contains($mime, 'gif') => @imagecreatefromgif($path), + default => false, + }; + + if (!$src) { + return $file->store($folder, 'public'); + } + + [$origW, $origH] = [imagesx($src), imagesy($src)]; + + // Scale down only — never upscale + $ratio = min(1.0, $cfg['w'] / $origW, $cfg['h'] / $origH); + $newW = (int) round($origW * $ratio); + $newH = (int) round($origH * $ratio); + + $dst = imagecreatetruecolor($newW, $newH); + + // Preserve transparency for PNG + if (str_contains($mime, 'png')) { + imagealphablending($dst, false); + imagesavealpha($dst, true); + $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); + imagefilledrectangle($dst, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH); + imagedestroy($src); + + // Output as WebP if supported, else JPEG + $ext = function_exists('imagewebp') ? 'webp' : 'jpg'; + $filename = $folder . '/' . Str::uuid() . '.' . $ext; + + ob_start(); + if ($ext === 'webp') { + imagewebp($dst, null, $cfg['quality']); + } else { + imagejpeg($dst, null, $cfg['quality']); + } + $bytes = ob_get_clean(); + imagedestroy($dst); + + Storage::disk('public')->put($filename, $bytes); + + return $filename; + } catch (\Throwable) { + return $file->store($folder, 'public'); + } + } + + public static function delete(?string $path): void + { + if ($path && !MediaUrl::isExternal($path)) { + Storage::disk('public')->delete($path); + } + } +} diff --git a/app/Support/MediaUrl.php b/app/Support/MediaUrl.php new file mode 100644 index 0000000..bc85be2 --- /dev/null +++ b/app/Support/MediaUrl.php @@ -0,0 +1,59 @@ + $segment !== '' + ); + + return implode('/', array_map('rawurlencode', $segments)); + } + + public static function isExternal(?string $path): bool + { + return $path !== null && Str::startsWith($path, ['http://', 'https://', '//', 'data:']); + } + + private static function isDirectUrl(string $path): bool + { + return self::isExternal($path); + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..dc69f1b --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,32 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'admin' => \App\Http\Middleware\AdminMiddleware::class, + 'admin.access' => \App\Http\Middleware\AdminAccessMiddleware::class, + 'import.api' => \App\Http\Middleware\ImportApiMiddleware::class, + 'secure.player' => \App\Http\Middleware\SecurePlayer::class, + ]); + // Giriş yapılmamış kullanıcıyı frontend login sayfasına yönlendir + $middleware->redirectGuestsTo(fn () => route('frontend.login')); + // Bot koruması tüm web isteklerine uygula + $middleware->web(\App\Http\Middleware\BotDetector::class); + // SEO yönlendirmeleri + $middleware->web(\App\Http\Middleware\SeoRedirectMiddleware::class); + // iyzico callback CSRF muaf + $middleware->validateCsrfTokens(except: ['checkout/callback']); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-03-10T16:41:02+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:27:06+00:00" + }, + { + "name": "iyzico/iyzipay-php", + "version": "v2.0.61", + "source": { + "type": "git", + "url": "https://github.com/iyzico/iyzipay-php.git", + "reference": "168839f9ccb2aebaa2cb9f07b883b1b2bb0cf87a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iyzico/iyzipay-php/zipball/168839f9ccb2aebaa2cb9f07b883b1b2bb0cf87a", + "reference": "168839f9ccb2aebaa2cb9f07b883b1b2bb0cf87a", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": ">=7.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.34", + "satooshi/php-coveralls": "~0.6.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Iyzipay\\": "src/Iyzipay/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "iyzico and contributors", + "homepage": "https://github.com/iyzico/iyzipay-php/contributors" + } + ], + "description": "iyzipay api php client", + "homepage": "https://www.iyzico.com", + "keywords": [ + "iyzico", + "iyzico.com", + "iyzipay", + "iyzipay api", + "iyzipay api php", + "iyzipay api php client", + "iyzipay php", + "payment processing" + ], + "support": { + "issues": "https://github.com/iyzico/iyzipay-php/issues", + "source": "https://github.com/iyzico/iyzipay-php/tree/v2.0.61" + }, + "time": "2026-04-28T11:31:34+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.56.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "dac16d424b59debb2273910dde88eb7050a2a709" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/dac16d424b59debb2273910dde88eb7050a2a709", + "reference": "dac16d424b59debb2273910dde88eb7050a2a709", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-03-26T14:51:54+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.16" + }, + "time": "2026-03-23T14:35:33+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-02-07T17:19:31+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.11", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-07T13:32:18+00:00" + }, + { + "name": "laravel/socialite", + "version": "v5.27.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-04-24T14:05:47+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "lcobucci/clock", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/coding-standard": "^11.1.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.25", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^11.3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.3.1" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2024-09-24T20:45:14+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.33.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "570b8871e0ce693764434b29154c54b434905350" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + }, + "time": "2026-03-25T07:59:30+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.3", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.3" + }, + "time": "2026-02-13T03:05:33+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.55", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-06-14T23:24:10+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.22", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" + }, + "time": "2026-03-22T23:03:24+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "socialiteproviders/apple", + "version": "5.10.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Apple.git", + "reference": "11d871eb8193c31a2230fdf56929952eaf81fac2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Apple/zipball/11d871eb8193c31a2230fdf56929952eaf81fac2", + "reference": "11d871eb8193c31a2230fdf56929952eaf81fac2", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "firebase/php-jwt": "^7.0", + "lcobucci/clock": "^2.0 || ^3.0", + "lcobucci/jwt": "^4.1.5 || ^5.0.0", + "php": "^8.0", + "socialiteproviders/manager": "^4.4" + }, + "suggest": { + "ahilmurugesan/socialite-apple-helper": "Automatic Apple client key generation and management." + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Apple\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ahilan", + "email": "ahilmurugesan@gmail.com", + "role": "Developer" + }, + { + "name": "Vamsi Krishna V", + "email": "vamsi@vonectech.com", + "homepage": "https://vonectech.com/", + "role": "Farmer" + } + ], + "description": "Apple OAuth2 Provider for Laravel Socialite", + "keywords": [ + "apple", + "apple client key", + "apple sign in", + "client key generator", + "client key refresh", + "laravel", + "laravel apple", + "laravel socialite", + "oauth", + "provider", + "sign in with apple", + "socialite", + "socialite apple" + ], + "support": { + "docs": "https://socialiteproviders.com/apple", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2026-03-22T23:23:52+00:00" + }, + { + "name": "socialiteproviders/discord", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Discord.git", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Discord\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Eklund", + "email": "eklundchristopher@gmail.com" + } + ], + "description": "Discord OAuth2 Provider for Laravel Socialite", + "keywords": [ + "discord", + "laravel", + "oauth", + "provider", + "socialite" + ], + "support": { + "docs": "https://socialiteproviders.com/discord", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2023-07-24T23:28:47+00:00" + }, + { + "name": "socialiteproviders/manager", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Manager.git", + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "shasum": "" + }, + "require": { + "illuminate/support": "^11.0 || ^12.0 || ^13.0", + "laravel/socialite": "^5.5", + "php": "^8.2" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "SocialiteProviders\\Manager\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SocialiteProviders\\Manager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Wendt", + "email": "andy@awendt.com" + }, + { + "name": "Anton Komarev", + "email": "a.komarev@cybercog.su" + }, + { + "name": "Miguel Piedrafita", + "email": "soy@miguelpiedrafita.com" + }, + { + "name": "atymic", + "email": "atymicq@gmail.com", + "homepage": "https://atymic.dev" + } + ], + "description": "Easily add new or override built-in providers in Laravel Socialite.", + "homepage": "https://socialiteproviders.com", + "keywords": [ + "laravel", + "manager", + "oauth", + "providers", + "socialite" + ], + "support": { + "issues": "https://github.com/socialiteproviders/manager/issues", + "source": "https://github.com/socialiteproviders/manager" + }, + "time": "2026-03-18T22:13:24+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:54:39+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b055f228a4178a1d6774909903905e3475f3eac8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8", + "reference": "b055f228a4178a1d6774909903905e3475f3eac8", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "f57b899fa736fd71121168ef268f23c206083f0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a", + "reference": "f57b899fa736fd71121168ef268f23c206083f0a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:54:39+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "017e76ad089bac281553389269e259e155935e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", + "reference": "017e76ad089bac281553389269e259e155935e1a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-31T20:57:01+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T14:11:46+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "114ac57257d75df748eda23dd003878080b8e688" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", + "reference": "114ac57257d75df748eda23dd003878080b8e688", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "33600f8489485425bfcddd0d983391038d3422e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/33600f8489485425bfcddd0d983391038d3422e7", + "reference": "33600f8489485425bfcddd0d983391038d3422e7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:44:50+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-02-09T13:44:54+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", + "laravel-zero/framework": "^12.0.5", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-03-12T15:51:39+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.56.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/f43426bb42a1cb7a51a3861d9138063e54766d28", + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-04-01T15:17:32+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.3", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/b0d8ab95b29c3189aeeb902d81215231df4c1b64", + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.4" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.3", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", + "laravel/pint": "^1.29.0", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-06T19:25:53+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..94fec15 --- /dev/null +++ b/config/app.php @@ -0,0 +1,128 @@ + 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(',', (string) 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'), + ], + + 'import_api_key' => env('IMPORT_API_KEY', ''), + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + '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', 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 number 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), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + 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", + | "failover", "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', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 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((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + 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, + 'transaction_mode' => 'DEFERRED', + ], + + '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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : 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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : 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' => env('DB_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((string) 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), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + 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' => rtrim(env('APP_URL', 'http://localhost'), '/').'/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'), + ], + +]; diff --git a/config/iyzico.php b/config/iyzico.php new file mode 100644 index 0000000..131bc9b --- /dev/null +++ b/config/iyzico.php @@ -0,0 +1,7 @@ + env('IYZICO_API_KEY', ''), + 'secret_key' => env('IYZICO_SECRET_KEY', ''), + 'base_url' => env('IYZICO_BASE_URL', 'https://sandbox-api.iyzipay.com'), +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + 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(',', (string) 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', env('APP_NAME', 'Laravel')), + '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, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + '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'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + 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((string) 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', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 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', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + 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", + | "deferred", "background", "failover", "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, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 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', + ], + +]; diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..fda2994 --- /dev/null +++ b/config/services.php @@ -0,0 +1,55 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + + 'firebase' => [ + 'project_id' => env('FIREBASE_PROJECT_ID', 'animexeapp'), + 'server_key' => env('FIREBASE_SERVER_KEY'), + ], + + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_REDIRECT_URI', '/auth/google/callback'), + ], + + 'discord' => [ + 'client_id' => env('DISCORD_CLIENT_ID'), + 'client_secret' => env('DISCORD_CLIENT_SECRET'), + 'redirect' => env('DISCORD_REDIRECT_URI', '/auth/discord/callback'), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + 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: "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((string) 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 without 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), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + 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, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..4ab7a73 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('name'); + $table->string('username')->unique()->nullable(); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('avatar')->nullable(); + $table->enum('role', ['user', 'moderator', 'admin'])->default('user'); + $table->enum('membership', ['free', 'premium'])->default('free'); + $table->dateTime('premium_expires_at')->nullable(); + $table->boolean('is_banned')->default(false); + $table->string('ban_reason')->nullable(); + $table->timestamp('banned_at')->nullable(); + $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(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +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'); + } +}; diff --git a/database/migrations/2024_01_01_000010_create_membership_plans_table.php b/database/migrations/2024_01_01_000010_create_membership_plans_table.php new file mode 100644 index 0000000..c901cf5 --- /dev/null +++ b/database/migrations/2024_01_01_000010_create_membership_plans_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->decimal('price', 8, 2); + $table->integer('duration_days'); // 30, 90, 365 + $table->json('features')->nullable(); // ["4K izleme", "Reklamsız", ...] + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('membership_plans'); + } +}; diff --git a/database/migrations/2024_01_01_000020_create_genres_table.php b/database/migrations/2024_01_01_000020_create_genres_table.php new file mode 100644 index 0000000..0a309ad --- /dev/null +++ b/database/migrations/2024_01_01_000020_create_genres_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('color', 7)->nullable(); // hex renk + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('genres'); + } +}; diff --git a/database/migrations/2024_01_01_000030_create_animes_table.php b/database/migrations/2024_01_01_000030_create_animes_table.php new file mode 100644 index 0000000..005b06d --- /dev/null +++ b/database/migrations/2024_01_01_000030_create_animes_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('title'); + $table->string('title_en')->nullable(); + $table->string('title_jp')->nullable(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('cover_image')->nullable(); + $table->string('banner_image')->nullable(); + $table->string('trailer_url')->nullable(); + $table->integer('release_year')->nullable(); + $table->enum('type', ['series', 'movie', 'ova', 'ona', 'special'])->default('series'); + $table->enum('status', ['ongoing', 'completed', 'upcoming'])->default('ongoing'); + $table->integer('episode_count')->default(0); + $table->decimal('rating', 3, 1)->default(0); + $table->string('studio')->nullable(); + $table->string('mal_id')->nullable(); // MyAnimeList ID + $table->boolean('is_featured')->default(false); + $table->boolean('is_published')->default(false); + $table->timestamps(); + }); + + Schema::create('anime_genre', function (Blueprint $table) { + $table->foreignId('anime_id')->constrained()->onDelete('cascade'); + $table->foreignId('genre_id')->constrained()->onDelete('cascade'); + $table->primary(['anime_id', 'genre_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('anime_genre'); + Schema::dropIfExists('animes'); + } +}; diff --git a/database/migrations/2024_01_01_000040_create_seasons_table.php b/database/migrations/2024_01_01_000040_create_seasons_table.php new file mode 100644 index 0000000..09cb313 --- /dev/null +++ b/database/migrations/2024_01_01_000040_create_seasons_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('anime_id')->constrained()->onDelete('cascade'); + $table->integer('season_number'); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->string('cover_image')->nullable(); + $table->integer('release_year')->nullable(); + $table->boolean('is_published')->default(true); + $table->timestamps(); + + $table->unique(['anime_id', 'season_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('seasons'); + } +}; diff --git a/database/migrations/2024_01_01_000050_create_episodes_table.php b/database/migrations/2024_01_01_000050_create_episodes_table.php new file mode 100644 index 0000000..90c0f11 --- /dev/null +++ b/database/migrations/2024_01_01_000050_create_episodes_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('anime_id')->constrained()->onDelete('cascade'); + $table->foreignId('season_id')->constrained()->onDelete('cascade'); + $table->integer('episode_number'); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->string('thumbnail')->nullable(); + $table->integer('duration')->nullable(); // saniye + // BunnyCDN + $table->string('bunny_video_id')->nullable(); + $table->string('bunny_library_id')->nullable(); + $table->string('video_url')->nullable(); // CDN pull URL + $table->string('m3u8_url')->nullable(); + // Kaynak + $table->string('source_url')->nullable(); // Anizium CDN URL + $table->enum('source', ['bunnycdn', 'external', 'direct'])->default('bunnycdn'); + // Durum + $table->enum('status', ['pending', 'processing', 'published', 'failed'])->default('pending'); + $table->integer('view_count')->default(0); + $table->boolean('is_published')->default(false); + $table->timestamps(); + + $table->unique(['season_id', 'episode_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('episodes'); + } +}; diff --git a/database/migrations/2024_01_01_000060_create_content_permissions_table.php b/database/migrations/2024_01_01_000060_create_content_permissions_table.php new file mode 100644 index 0000000..39dfd38 --- /dev/null +++ b/database/migrations/2024_01_01_000060_create_content_permissions_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('key')->unique(); // örn: "can_comment", "can_watch", "can_rate" + $table->string('label'); // Yönetici panelinde görünen ad + $table->enum('required_membership', ['free', 'premium'])->default('free'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + // İçeriğe özel izin override'ları + Schema::create('content_permissions', function (Blueprint $table) { + $table->id(); + $table->string('content_type'); // 'anime', 'episode', 'season' + $table->unsignedBigInteger('content_id'); + $table->string('permission_key'); // 'can_watch', 'can_comment', vb. + $table->enum('required_membership', ['free', 'premium']); + $table->timestamps(); + + $table->unique(['content_type', 'content_id', 'permission_key'], 'cp_unique'); + $table->index(['content_type', 'content_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('content_permissions'); + Schema::dropIfExists('permission_settings'); + } +}; diff --git a/database/migrations/2024_01_01_000070_create_comments_table.php b/database/migrations/2024_01_01_000070_create_comments_table.php new file mode 100644 index 0000000..e4ae929 --- /dev/null +++ b/database/migrations/2024_01_01_000070_create_comments_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('commentable_type'); // 'anime', 'episode' + $table->unsignedBigInteger('commentable_id'); + $table->foreignId('parent_id')->nullable()->constrained('comments')->onDelete('cascade'); + $table->text('content'); + $table->enum('status', ['pending', 'approved', 'rejected', 'spam'])->default('approved'); + $table->boolean('is_pinned')->default(false); + $table->integer('like_count')->default(0); + $table->timestamps(); + + $table->index(['commentable_type', 'commentable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/database/migrations/2024_01_01_000080_create_subscriptions_table.php b/database/migrations/2024_01_01_000080_create_subscriptions_table.php new file mode 100644 index 0000000..1a9f89e --- /dev/null +++ b/database/migrations/2024_01_01_000080_create_subscriptions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('plan_id')->constrained('membership_plans')->onDelete('cascade'); + $table->enum('status', ['active', 'expired', 'cancelled'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->string('payment_method')->nullable(); // 'manual', 'stripe', vb. + $table->string('payment_ref')->nullable(); + $table->text('notes')->nullable(); // admin notu + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2024_01_01_000090_create_banners_table.php b/database/migrations/2024_01_01_000090_create_banners_table.php new file mode 100644 index 0000000..5115cad --- /dev/null +++ b/database/migrations/2024_01_01_000090_create_banners_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('title'); + $table->string('image'); + $table->string('link')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('banners'); + } +}; diff --git a/database/migrations/2024_01_01_000100_create_settings_table.php b/database/migrations/2024_01_01_000100_create_settings_table.php new file mode 100644 index 0000000..9c9c97c --- /dev/null +++ b/database/migrations/2024_01_01_000100_create_settings_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('group')->default('general'); // 'general', 'appearance', 'payment', vb. + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2024_01_01_000110_create_import_jobs_table.php b/database/migrations/2024_01_01_000110_create_import_jobs_table.php new file mode 100644 index 0000000..a91312c --- /dev/null +++ b/database/migrations/2024_01_01_000110_create_import_jobs_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('source_url')->nullable(); + $table->string('watch_id')->nullable(); + $table->string('cdn_id')->nullable(); // 85937 — XDM'den alınan CDN ID + $table->string('anime_title')->nullable(); + $table->foreignId('anime_id')->nullable()->constrained('animes')->nullOnDelete(); + // Sezon/bölüm aralıkları (JSON array: [{"season":1,"from":1,"to":24}]) + $table->json('season_ranges')->nullable(); + $table->enum('status', ['pending','fetching','downloading','uploading','done','failed'])->default('pending'); + $table->integer('total_episodes')->default(0); + $table->integer('done_episodes')->default(0); + $table->integer('failed_episodes')->default(0); + $table->text('current_step')->nullable(); + $table->text('error_log')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_jobs'); + } +}; diff --git a/database/migrations/2024_01_01_000120_add_animecix_fields_to_import_jobs.php b/database/migrations/2024_01_01_000120_add_animecix_fields_to_import_jobs.php new file mode 100644 index 0000000..c4b71b5 --- /dev/null +++ b/database/migrations/2024_01_01_000120_add_animecix_fields_to_import_jobs.php @@ -0,0 +1,23 @@ +string('source', 30)->default('anizium')->after('id'); + $table->string('animecix_title_id')->nullable()->after('source'); + $table->string('animecix_slug')->nullable()->after('animecix_title_id'); + }); + } + + public function down(): void + { + Schema::table('import_jobs', function (Blueprint $table) { + $table->dropColumn(['source', 'animecix_title_id', 'animecix_slug']); + }); + } +}; diff --git a/database/migrations/2024_01_01_000120_create_subtitles_table.php b/database/migrations/2024_01_01_000120_create_subtitles_table.php new file mode 100644 index 0000000..f4ccd93 --- /dev/null +++ b/database/migrations/2024_01_01_000120_create_subtitles_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('episode_id')->constrained('episodes')->cascadeOnDelete(); + $table->string('language', 10)->default('tr'); // tr, en, jp + $table->string('label')->default('Türkçe'); // Görünen ad + $table->string('url'); // VTT dosya URL'si + $table->boolean('is_default')->default(false); + $table->timestamps(); + $table->unique(['episode_id', 'language']); + }); + } + + public function down(): void + { + Schema::dropIfExists('subtitles'); + } +}; diff --git a/database/migrations/2024_01_01_000121_create_video_sources_table.php b/database/migrations/2024_01_01_000121_create_video_sources_table.php new file mode 100644 index 0000000..009b06c --- /dev/null +++ b/database/migrations/2024_01_01_000121_create_video_sources_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('episode_id')->constrained('episodes')->cascadeOnDelete(); + $table->string('label', 120)->default(''); + $table->text('url'); + $table->enum('type', ['mp4', 'hls', 'embed'])->default('mp4'); + $table->string('quality', 20)->default(''); + $table->string('translator_id', 60)->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->boolean('is_default')->default(false); + $table->string('source', 30)->default('animecix'); + $table->timestamps(); + + $table->index('episode_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('video_sources'); + } +}; diff --git a/database/migrations/2024_01_01_000122_add_animecix_to_episodes_source_enum.php b/database/migrations/2024_01_01_000122_add_animecix_to_episodes_source_enum.php new file mode 100644 index 0000000..a106b01 --- /dev/null +++ b/database/migrations/2024_01_01_000122_add_animecix_to_episodes_source_enum.php @@ -0,0 +1,17 @@ +string('gif_url', 500)->nullable()->after('content'); + }); + + Schema::create('comment_likes', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('comment_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + $table->unique(['user_id', 'comment_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('comment_likes'); + Schema::table('comments', function (Blueprint $table) { + $table->dropColumn('gif_url'); + }); + } +}; diff --git a/database/migrations/2024_01_01_000140_add_available_dubs_to_episodes.php b/database/migrations/2024_01_01_000140_add_available_dubs_to_episodes.php new file mode 100644 index 0000000..1de25fb --- /dev/null +++ b/database/migrations/2024_01_01_000140_add_available_dubs_to_episodes.php @@ -0,0 +1,22 @@ +text('available_dubs')->nullable()->after('m3u8_url'); + }); + } + + public function down(): void + { + Schema::table('episodes', function (Blueprint $table) { + $table->dropColumn('available_dubs'); + }); + } +}; diff --git a/database/migrations/2024_01_01_000150_add_trending_to_animes.php b/database/migrations/2024_01_01_000150_add_trending_to_animes.php new file mode 100644 index 0000000..90c14d9 --- /dev/null +++ b/database/migrations/2024_01_01_000150_add_trending_to_animes.php @@ -0,0 +1,22 @@ +boolean('is_trending')->default(false)->after('is_featured'); + $table->unsignedSmallInteger('trending_order')->default(0)->after('is_trending'); + }); + } + + public function down(): void + { + Schema::table('animes', function (Blueprint $table) { + $table->dropColumn(['is_trending', 'trending_order']); + }); + } +}; diff --git a/database/migrations/2024_01_01_000160_create_analytics_tables.php b/database/migrations/2024_01_01_000160_create_analytics_tables.php new file mode 100644 index 0000000..bf22455 --- /dev/null +++ b/database/migrations/2024_01_01_000160_create_analytics_tables.php @@ -0,0 +1,75 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id', 64)->nullable(); + $table->string('url', 500)->nullable(); + $table->string('page_type', 30)->default('other'); // home, anime, player, search, ai, genre, other + $table->foreignId('anime_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('episode_id')->nullable()->constrained()->nullOnDelete(); + $table->string('ip', 45)->nullable(); + $table->string('country', 100)->nullable(); + $table->string('city', 100)->nullable(); + $table->string('device', 20)->default('unknown'); // mobile, desktop, tablet + $table->string('browser', 50)->nullable(); + $table->string('referrer', 500)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['created_at']); + $table->index(['page_type', 'created_at']); + $table->index(['anime_id', 'created_at']); + $table->index(['user_id', 'created_at']); + $table->index(['ip']); + }); + + // Video izleme olayları + Schema::create('analytics_watch_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id', 64)->nullable(); + $table->foreignId('anime_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('episode_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedSmallInteger('season_number')->default(1); + $table->unsignedSmallInteger('episode_number')->default(1); + $table->unsignedInteger('seconds_watched')->default(0); + $table->unsignedInteger('total_seconds')->default(0); + $table->unsignedTinyInteger('percent_complete')->default(0); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['anime_id', 'created_at']); + $table->index(['episode_id', 'created_at']); + $table->index(['user_id', 'created_at']); + $table->index(['created_at']); + }); + + // AI sorgu logları + Schema::create('analytics_ai_queries', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('query_type', 30); // chat, recommend, search, episode_info, similar + $table->text('query_text')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['query_type', 'created_at']); + $table->index(['user_id', 'created_at']); + $table->index(['created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_ai_queries'); + Schema::dropIfExists('analytics_watch_events'); + Schema::dropIfExists('analytics_pageviews'); + } +}; diff --git a/database/migrations/2024_01_01_000170_create_user_features_tables.php b/database/migrations/2024_01_01_000170_create_user_features_tables.php new file mode 100644 index 0000000..d3f4806 --- /dev/null +++ b/database/migrations/2024_01_01_000170_create_user_features_tables.php @@ -0,0 +1,126 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->unsignedSmallInteger('season_number')->default(1); + $table->unsignedSmallInteger('episode_number')->default(1); + $table->unsignedInteger('seconds_watched')->default(0); + $table->unsignedInteger('total_seconds')->default(0); + $table->unsignedTinyInteger('percent_complete')->default(0); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + + $table->unique(['user_id', 'anime_id']); + $table->index(['user_id', 'updated_at']); + }); + + // İzleme listesi + Schema::create('watchlists', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->string('status', 20)->default('plan'); // plan, watching, completed, dropped + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['user_id', 'anime_id']); + $table->index(['user_id', 'status']); + }); + + // Bölüm like/dislike + Schema::create('episode_votes', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->tinyInteger('vote')->default(1); // 1=like, -1=dislike + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['user_id', 'episode_id']); + $table->index(['episode_id', 'vote']); + }); + + // Anime puanlama (kullanıcı bazlı 1-10) + Schema::create('anime_ratings', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('rating'); // 1-10 + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + + $table->unique(['user_id', 'anime_id']); + $table->index(['anime_id']); + }); + + // Anime istekleri + Schema::create('anime_requests', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('title', 200); + $table->string('original_title', 200)->nullable(); + $table->text('note')->nullable(); + $table->string('status', 20)->default('pending'); // pending, approved, rejected, added + $table->text('admin_note')->nullable(); + $table->unsignedInteger('vote_count')->default(1); // kaç kişi istedi + $table->timestamps(); + + $table->index(['status', 'vote_count']); + }); + + // Anime isteği oyları (aynı anime'yi birden fazla kişi isteyebilir) + Schema::create('anime_request_votes', function (Blueprint $table) { + $table->id(); + $table->foreignId('anime_request_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('ip', 45)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['anime_request_id', 'user_id']); + }); + + // Başarım tanımları + Schema::create('achievements', function (Blueprint $table) { + $table->id(); + $table->string('key', 50)->unique(); + $table->string('title', 100); + $table->string('description', 255); + $table->string('icon', 50)->default('bi-trophy'); + $table->string('color', 20)->default('#f0883e'); + $table->string('condition_type', 30); // episodes_watched, hours_watched, watchlist_count, login_streak, request_approved + $table->unsignedInteger('condition_value')->default(1); + $table->timestamps(); + }); + + // Kullanıcı başarımları + Schema::create('user_achievements', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('achievement_id')->constrained()->cascadeOnDelete(); + $table->timestamp('earned_at')->useCurrent(); + + $table->unique(['user_id', 'achievement_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_achievements'); + Schema::dropIfExists('achievements'); + Schema::dropIfExists('anime_request_votes'); + Schema::dropIfExists('anime_requests'); + Schema::dropIfExists('anime_ratings'); + Schema::dropIfExists('episode_votes'); + Schema::dropIfExists('watchlists'); + Schema::dropIfExists('continue_watching'); + } +}; diff --git a/database/migrations/2024_01_01_000180_create_follow_notify_tables.php b/database/migrations/2024_01_01_000180_create_follow_notify_tables.php new file mode 100644 index 0000000..8f2c558 --- /dev/null +++ b/database/migrations/2024_01_01_000180_create_follow_notify_tables.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at')->useCurrent(); + $table->unique(['user_id', 'anime_id']); + $table->index(['anime_id']); + }); + + // Kullanıcı bildirimleri + Schema::create('user_notifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 50)->default('episode'); + $table->json('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->index(['user_id', 'read_at']); + $table->index(['user_id', 'created_at']); + }); + + // Bölüm notları + Schema::create('episode_notes', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->text('content'); + $table->unsignedInteger('timestamp_at')->nullable(); + $table->timestamps(); + $table->index(['user_id', 'episode_id']); + $table->index(['user_id', 'anime_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('episode_notes'); + Schema::dropIfExists('user_notifications'); + Schema::dropIfExists('anime_follows'); + } +}; diff --git a/database/migrations/2024_01_01_000200_add_intro_times_to_episodes.php b/database/migrations/2024_01_01_000200_add_intro_times_to_episodes.php new file mode 100644 index 0000000..4384f6d --- /dev/null +++ b/database/migrations/2024_01_01_000200_add_intro_times_to_episodes.php @@ -0,0 +1,18 @@ +unsignedSmallInteger('intro_start')->nullable()->after('duration'); + $table->unsignedSmallInteger('intro_end')->nullable()->after('intro_start'); + }); + } + public function down(): void { + Schema::table('episodes', function (Blueprint $table) { + $table->dropColumn(['intro_start', 'intro_end']); + }); + } +}; diff --git a/database/migrations/2024_01_01_000200_add_profile_fields_to_users.php b/database/migrations/2024_01_01_000200_add_profile_fields_to_users.php new file mode 100644 index 0000000..18b4e56 --- /dev/null +++ b/database/migrations/2024_01_01_000200_add_profile_fields_to_users.php @@ -0,0 +1,34 @@ +text('bio')->nullable()->after('avatar'); + $table->string('banner_image')->nullable()->after('bio'); + $table->string('website', 200)->nullable()->after('banner_image'); + $table->string('twitter', 100)->nullable()->after('website'); + $table->string('instagram', 100)->nullable()->after('twitter'); + $table->string('discord', 100)->nullable()->after('instagram'); + $table->string('profile_color', 7)->nullable()->default('#00f5ff')->after('discord'); + $table->boolean('show_watchlist')->default(true)->after('profile_color'); + $table->boolean('show_activity')->default(true)->after('show_watchlist'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'bio', 'banner_image', 'website', 'twitter', + 'instagram', 'discord', 'profile_color', + 'show_watchlist', 'show_activity', + ]); + }); + } +}; diff --git a/database/migrations/2024_01_01_000210_add_fcm_token_to_users.php b/database/migrations/2024_01_01_000210_add_fcm_token_to_users.php new file mode 100644 index 0000000..58d9c79 --- /dev/null +++ b/database/migrations/2024_01_01_000210_add_fcm_token_to_users.php @@ -0,0 +1,22 @@ +string('fcm_token', 500)->nullable()->after('remember_token'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('fcm_token'); + }); + } +}; diff --git a/database/migrations/2024_01_02_000001_add_updated_at_to_watchlists.php b/database/migrations/2024_01_02_000001_add_updated_at_to_watchlists.php new file mode 100644 index 0000000..d2c0786 --- /dev/null +++ b/database/migrations/2024_01_02_000001_add_updated_at_to_watchlists.php @@ -0,0 +1,20 @@ +timestamp('updated_at')->nullable()->useCurrent()->useCurrentOnUpdate()->after('created_at'); + }); + } + + public function down(): void + { + Schema::table('watchlists', function (Blueprint $table) { + $table->dropColumn('updated_at'); + }); + } +}; diff --git a/database/migrations/2024_01_02_000002_create_skip_events_table.php b/database/migrations/2024_01_02_000002_create_skip_events_table.php new file mode 100644 index 0000000..c5324f1 --- /dev/null +++ b/database/migrations/2024_01_02_000002_create_skip_events_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('from_sec'); // seek FROM + $table->unsignedInteger('to_sec'); // seek TO + $table->timestamp('created_at')->useCurrent(); + $table->index(['episode_id', 'from_sec']); + }); + } + + public function down(): void + { + Schema::dropIfExists('episode_skip_events'); + } +}; diff --git a/database/migrations/2024_01_02_000003_add_mal_id_to_seasons.php b/database/migrations/2024_01_02_000003_add_mal_id_to_seasons.php new file mode 100644 index 0000000..9c8dc91 --- /dev/null +++ b/database/migrations/2024_01_02_000003_add_mal_id_to_seasons.php @@ -0,0 +1,19 @@ +string('mal_id')->nullable()->after('season_number'); + }); + } + public function down(): void + { + Schema::table('seasons', function (Blueprint $table) { + $table->dropColumn('mal_id'); + }); + } +}; diff --git a/database/migrations/2026_04_10_150117_create_personal_access_tokens_table.php b/database/migrations/2026_04_10_150117_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_04_10_150117_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_17_000001_create_blog_posts_table.php b/database/migrations/2026_04_17_000001_create_blog_posts_table.php new file mode 100644 index 0000000..29d729c --- /dev/null +++ b/database/migrations/2026_04_17_000001_create_blog_posts_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('content')->nullable(); + $table->string('cover_image')->nullable(); + $table->string('focus_keyword')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->string('meta_keywords')->nullable(); + $table->enum('status', ['draft', 'published', 'generating'])->default('draft'); + $table->boolean('ai_generated')->default(false); + $table->foreignId('anime_id')->nullable()->constrained('animes')->nullOnDelete(); + $table->json('linked_anime_ids')->nullable(); + $table->json('faq')->nullable(); + $table->unsignedInteger('views')->default(0); + $table->unsignedSmallInteger('reading_time')->default(5); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('blog_posts'); + } +}; diff --git a/database/migrations/2026_04_17_100001_create_user_follows_table.php b/database/migrations/2026_04_17_100001_create_user_follows_table.php new file mode 100644 index 0000000..f8d355f --- /dev/null +++ b/database/migrations/2026_04_17_100001_create_user_follows_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedBigInteger('follower_id'); + $table->unsignedBigInteger('following_id'); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['follower_id', 'following_id']); + $table->foreign('follower_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('following_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_follows'); + } +}; diff --git a/database/migrations/2026_04_17_100002_create_conversations_table.php b/database/migrations/2026_04_17_100002_create_conversations_table.php new file mode 100644 index 0000000..ca38ddb --- /dev/null +++ b/database/migrations/2026_04_17_100002_create_conversations_table.php @@ -0,0 +1,45 @@ +id(); + $table->timestamps(); + }); + + Schema::create('conversation_participants', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('conversation_id'); + $table->unsignedBigInteger('user_id'); + $table->timestamp('last_read_at')->nullable(); + + $table->unique(['conversation_id', 'user_id']); + $table->foreign('conversation_id')->references('id')->on('conversations')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + + Schema::create('messages', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('conversation_id'); + $table->unsignedBigInteger('user_id'); + $table->text('body'); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('conversation_id')->references('id')->on('conversations')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->index(['conversation_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('messages'); + Schema::dropIfExists('conversation_participants'); + Schema::dropIfExists('conversations'); + } +}; diff --git a/database/migrations/2026_04_17_200001_create_social_features_tables.php b/database/migrations/2026_04_17_200001_create_social_features_tables.php new file mode 100644 index 0000000..08cc563 --- /dev/null +++ b/database/migrations/2026_04_17_200001_create_social_features_tables.php @@ -0,0 +1,104 @@ +id(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedSmallInteger('timestamp_sec'); // videonun kaçıncı saniyesi + $table->string('body', 100); // max 100 karakter, kısa kalasın + $table->string('color', 7)->default('#ffffff'); // hex renk + $table->boolean('is_hidden')->default(false); // mod silme + $table->timestamp('created_at')->useCurrent(); + + $table->index(['episode_id', 'timestamp_sec']); + $table->index(['user_id']); + }); + + // 2) Tahmin oyunu — bölüm öncesi tahminler + Schema::create('episode_predictions', function (Blueprint $table) { + $table->id(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('body', 280); // tahmin metni + $table->boolean('is_correct')->nullable(); // null=beklemede, true/false=sonuç + $table->unsignedSmallInteger('vote_count')->default(0); + $table->timestamps(); + + $table->unique(['episode_id', 'user_id']); // bölüm başına 1 tahmin + $table->index(['episode_id', 'vote_count']); + }); + + // 3) Tahmin oyları + Schema::create('prediction_votes', function (Blueprint $table) { + $table->id(); + $table->foreignId('prediction_id')->constrained('episode_predictions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['prediction_id', 'user_id']); + }); + + // 4) Watch Party odaları + Schema::create('watch_parties', function (Blueprint $table) { + $table->id(); + $table->string('room_code', 10)->unique(); + $table->foreignId('host_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('current_sec')->default(0); // senkron zaman + $table->boolean('is_playing')->default(false); + $table->timestamp('synced_at')->useCurrent()->useCurrentOnUpdate(); + $table->unsignedTinyInteger('max_members')->default(10); + $table->boolean('is_private')->default(false); + $table->string('password', 60)->nullable(); + $table->timestamps(); + + $table->index(['room_code']); + $table->index(['episode_id']); + }); + + // 5) Watch Party üyeleri + Schema::create('watch_party_members', function (Blueprint $table) { + $table->id(); + $table->foreignId('party_id')->constrained('watch_parties')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('joined_at')->useCurrent(); + $table->timestamp('last_ping')->useCurrent()->useCurrentOnUpdate(); + + $table->unique(['party_id', 'user_id']); + $table->index(['party_id', 'last_ping']); + }); + + // 6) İlk kez izleyenler — aktif oturum kaydı (60dk TTL, cron temizler) + Schema::create('first_watch_sessions', function (Blueprint $table) { + $table->id(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id', 64)->nullable(); // misafir desteği + $table->boolean('is_first_time')->default(true); // kullanıcı "ilk kez" dedi mi + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('last_seen')->useCurrent()->useCurrentOnUpdate(); + + $table->index(['episode_id', 'last_seen']); + $table->index(['user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('first_watch_sessions'); + Schema::dropIfExists('watch_party_members'); + Schema::dropIfExists('watch_parties'); + Schema::dropIfExists('prediction_votes'); + Schema::dropIfExists('episode_predictions'); + Schema::dropIfExists('episode_timestamp_comments'); + } +}; diff --git a/database/migrations/2026_04_17_300000_create_community_features_tables.php b/database/migrations/2026_04_17_300000_create_community_features_tables.php new file mode 100644 index 0000000..60cd71e --- /dev/null +++ b/database/migrations/2026_04_17_300000_create_community_features_tables.php @@ -0,0 +1,102 @@ +id(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->foreignId('episode_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('created_by')->constrained('users')->cascadeOnDelete(); + $table->string('question', 280); + $table->string('side_a', 100); + $table->string('side_b', 100); + $table->enum('status', ['open', 'closed'])->default('open'); + $table->string('verdict', 1)->nullable(); + $table->timestamp('closes_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('tribunal_votes', function (Blueprint $table) { + $table->id(); + $table->foreignId('tribunal_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('side', 1); + $table->timestamp('created_at'); + $table->unique(['tribunal_id', 'user_id']); + }); + + Schema::create('tribunal_arguments', function (Blueprint $table) { + $table->id(); + $table->foreignId('tribunal_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('side', 1); + $table->string('body', 500); + $table->unsignedSmallInteger('vote_count')->default(0); + $table->timestamps(); + $table->unique(['tribunal_id', 'user_id']); + }); + + Schema::create('tribunal_argument_votes', function (Blueprint $table) { + $table->id(); + $table->foreignId('argument_id')->constrained('tribunal_arguments')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at'); + $table->unique(['argument_id', 'user_id']); + }); + + Schema::create('time_capsules', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->text('message'); + $table->timestamp('unlock_at'); + $table->timestamp('opened_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('spoiler_boxes', function (Blueprint $table) { + $table->id(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('body'); + $table->boolean('is_spoiler')->default(false); + $table->tinyInteger('spoiler_score')->default(0); + $table->unsignedSmallInteger('likes')->default(0); + $table->timestamps(); + }); + + Schema::create('spoiler_box_likes', function (Blueprint $table) { + $table->id(); + $table->foreignId('box_id')->constrained('spoiler_boxes')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at'); + $table->unique(['box_id', 'user_id']); + }); + + Schema::create('mood_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('episode_id')->constrained()->cascadeOnDelete(); + $table->string('mood', 20); + $table->timestamp('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('mood_logs'); + Schema::dropIfExists('spoiler_box_likes'); + Schema::dropIfExists('spoiler_boxes'); + Schema::dropIfExists('time_capsules'); + Schema::dropIfExists('tribunal_argument_votes'); + Schema::dropIfExists('tribunal_arguments'); + Schema::dropIfExists('tribunal_votes'); + Schema::dropIfExists('tribunals'); + } +}; diff --git a/database/migrations/2026_04_17_310000_add_extra_sides_to_tribunals.php b/database/migrations/2026_04_17_310000_add_extra_sides_to_tribunals.php new file mode 100644 index 0000000..f05420a --- /dev/null +++ b/database/migrations/2026_04_17_310000_add_extra_sides_to_tribunals.php @@ -0,0 +1,31 @@ +json('extra_sides')->nullable()->after('side_b'); + }); + + // Oy sütununu genişlet (a-z yeterliydi zaten, varchar(1) → varchar(2) yine de) + Schema::table('tribunal_votes', function (Blueprint $table) { + $table->string('side', 2)->change(); + }); + + Schema::table('tribunal_arguments', function (Blueprint $table) { + $table->string('side', 2)->change(); + }); + } + + public function down(): void + { + Schema::table('tribunals', function (Blueprint $table) { + $table->dropColumn('extra_sides'); + }); + } +}; diff --git a/database/migrations/2026_04_19_000001_create_moderator_permissions_table.php b/database/migrations/2026_04_19_000001_create_moderator_permissions_table.php new file mode 100644 index 0000000..c53cc59 --- /dev/null +++ b/database/migrations/2026_04_19_000001_create_moderator_permissions_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('permission', 80); + $table->foreignId('granted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['user_id', 'permission']); + $table->index('user_id'); + }); + + // user_activity_logs — every significant action a logged-in user takes + Schema::create('user_activity_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id', 64)->nullable()->index(); + $table->string('action', 60); // login, logout, comment, watchlist_add… + $table->string('subject_type', 60)->nullable(); // Anime, Episode, Comment… + $table->unsignedBigInteger('subject_id')->nullable(); + $table->string('ip', 45)->nullable()->index(); + $table->string('country', 80)->nullable()->index(); + $table->string('city', 80)->nullable(); + $table->string('device', 20)->nullable(); + $table->string('browser', 50)->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->boolean('is_bot')->default(false)->index(); + $table->json('meta')->nullable(); // extra context + $table->timestamp('created_at')->useCurrent()->index(); + + $table->index(['user_id', 'created_at']); + $table->index(['action', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_activity_logs'); + Schema::dropIfExists('moderator_permissions'); + } +}; diff --git a/database/migrations/2026_04_19_000002_add_bot_columns_to_analytics_pageviews.php b/database/migrations/2026_04_19_000002_add_bot_columns_to_analytics_pageviews.php new file mode 100644 index 0000000..eadfe28 --- /dev/null +++ b/database/migrations/2026_04_19_000002_add_bot_columns_to_analytics_pageviews.php @@ -0,0 +1,29 @@ +boolean('is_bot')->default(false)->index()->after('referrer'); + } + if (!Schema::hasColumn('analytics_pageviews', 'user_agent')) { + $table->string('user_agent', 500)->nullable()->after('is_bot'); + } + if (!Schema::hasColumn('analytics_pageviews', 'time_on_page')) { + $table->unsignedSmallInteger('time_on_page')->default(0)->after('user_agent'); + } + }); + } + + public function down(): void + { + Schema::table('analytics_pageviews', function (Blueprint $table) { + $table->dropColumn(['is_bot', 'user_agent', 'time_on_page']); + }); + } +}; diff --git a/database/migrations/2026_05_09_040058_add_anizium_to_episodes_source_enum.php b/database/migrations/2026_05_09_040058_add_anizium_to_episodes_source_enum.php new file mode 100644 index 0000000..f8ed2e3 --- /dev/null +++ b/database/migrations/2026_05_09_040058_add_anizium_to_episodes_source_enum.php @@ -0,0 +1,19 @@ +json('perks')->nullable()->after('features'); + }); + } + + public function down(): void + { + Schema::table('membership_plans', function (Blueprint $table) { + $table->dropColumn('perks'); + }); + } +}; diff --git a/database/migrations/2026_05_10_100001_add_premium_cosmetics_to_users.php b/database/migrations/2026_05_10_100001_add_premium_cosmetics_to_users.php new file mode 100644 index 0000000..e7d461b --- /dev/null +++ b/database/migrations/2026_05_10_100001_add_premium_cosmetics_to_users.php @@ -0,0 +1,31 @@ +string('gif_avatar', 500)->nullable()->after('avatar'); + $table->string('comment_bg', 32)->nullable()->after('gif_avatar'); // fire|aurora|stars|sakura|neon|galaxy|ice + $table->string('username_color', 32)->nullable()->after('comment_bg'); // preset key + $table->string('profile_frame', 32)->nullable()->after('username_color'); // neon|fire|sakura|galaxy|gold|ice + $table->string('profile_badge', 64)->nullable()->after('profile_frame'); // özel unvan metni + $table->string('profile_bg', 32)->nullable()->after('profile_badge'); // animasyon stili + $table->string('profile_theme', 32)->nullable()->after('profile_bg'); // tema anahtarı + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'gif_avatar', 'comment_bg', 'username_color', + 'profile_frame', 'profile_badge', 'profile_bg', 'profile_theme', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_14_000001_add_plan_visibility_and_admin_badge.php b/database/migrations/2026_05_14_000001_add_plan_visibility_and_admin_badge.php new file mode 100644 index 0000000..b32f17d --- /dev/null +++ b/database/migrations/2026_05_14_000001_add_plan_visibility_and_admin_badge.php @@ -0,0 +1,44 @@ +boolean('is_public')->default(true)->after('is_active'); + } + if (!Schema::hasColumn('membership_plans', 'visible_until')) { + $table->timestamp('visible_until')->nullable()->after('is_public'); + } + }); + + Schema::table('users', function (Blueprint $table) { + if (!Schema::hasColumn('users', 'admin_badge')) { + $table->string('admin_badge', 32)->nullable(); + } + }); + } + + public function down(): void + { + Schema::table('membership_plans', function (Blueprint $table) { + if (Schema::hasColumn('membership_plans', 'visible_until')) { + $table->dropColumn('visible_until'); + } + if (Schema::hasColumn('membership_plans', 'is_public')) { + $table->dropColumn('is_public'); + } + }); + + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'admin_badge')) { + $table->dropColumn('admin_badge'); + } + }); + } +}; diff --git a/database/migrations/2026_05_14_000002_add_trial_days_and_profile_music.php b/database/migrations/2026_05_14_000002_add_trial_days_and_profile_music.php new file mode 100644 index 0000000..8c9db9a --- /dev/null +++ b/database/migrations/2026_05_14_000002_add_trial_days_and_profile_music.php @@ -0,0 +1,38 @@ +unsignedSmallInteger('trial_days')->default(0)->after('duration_days'); + } + }); + + Schema::table('users', function (Blueprint $table) { + if (!Schema::hasColumn('users', 'profile_music_url')) { + $table->string('profile_music_url', 500)->nullable(); + } + }); + } + + public function down(): void + { + Schema::table('membership_plans', function (Blueprint $table) { + if (Schema::hasColumn('membership_plans', 'trial_days')) { + $table->dropColumn('trial_days'); + } + }); + + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'profile_music_url')) { + $table->dropColumn('profile_music_url'); + } + }); + } +}; diff --git a/database/migrations/2026_06_05_000001_add_trending_score_to_animes.php b/database/migrations/2026_06_05_000001_add_trending_score_to_animes.php new file mode 100644 index 0000000..e961cfc --- /dev/null +++ b/database/migrations/2026_06_05_000001_add_trending_score_to_animes.php @@ -0,0 +1,23 @@ +float('trending_score', 10, 2)->default(0)->after('trending_order'); + $table->index('trending_score'); + }); + } + + public function down(): void + { + Schema::table('animes', function (Blueprint $table) { + $table->dropColumn('trending_score'); + }); + } +}; diff --git a/database/migrations/2026_06_05_000002_add_priority_to_import_jobs.php b/database/migrations/2026_06_05_000002_add_priority_to_import_jobs.php new file mode 100644 index 0000000..b38f786 --- /dev/null +++ b/database/migrations/2026_06_05_000002_add_priority_to_import_jobs.php @@ -0,0 +1,25 @@ +tinyInteger('priority')->default(0)->after('status'); + $table->index(['status', 'priority', 'id']); + }); + } + + public function down(): void + { + Schema::table('import_jobs', function (Blueprint $table) { + $table->dropColumn('priority'); + }); + } +}; diff --git a/database/migrations/2026_06_09_000001_create_anime_swipes_table.php b/database/migrations/2026_06_09_000001_create_anime_swipes_table.php new file mode 100644 index 0000000..940d4ae --- /dev/null +++ b/database/migrations/2026_06_09_000001_create_anime_swipes_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('anime_id')->constrained()->cascadeOnDelete(); + $table->enum('direction', ['like', 'skip']); + $table->timestamp('created_at')->useCurrent(); + $table->unique(['user_id', 'anime_id']); + $table->index('user_id'); + }); + + Schema::table('animes', function (Blueprint $table) { + $table->text('discovery_hook')->nullable()->after('description'); + }); + } + + public function down(): void + { + Schema::dropIfExists('anime_swipes'); + Schema::table('animes', function (Blueprint $table) { + $table->dropColumn('discovery_hook'); + }); + } +}; diff --git a/database/migrations/2026_06_10_000001_add_purchase_fields_to_membership_plans.php b/database/migrations/2026_06_10_000001_add_purchase_fields_to_membership_plans.php new file mode 100644 index 0000000..5e7c0d9 --- /dev/null +++ b/database/migrations/2026_06_10_000001_add_purchase_fields_to_membership_plans.php @@ -0,0 +1,24 @@ +string('purchase_link', 1000)->nullable()->after('price'); + $table->string('badge_label', 32)->nullable()->after('sort_order'); + $table->string('accent_color', 16)->nullable()->after('badge_label'); + }); + } + + public function down(): void + { + Schema::table('membership_plans', function (Blueprint $table) { + $table->dropColumn(['purchase_link', 'badge_label', 'accent_color']); + }); + } +}; diff --git a/database/migrations/2026_06_10_000002_create_activation_codes_table.php b/database/migrations/2026_06_10_000002_create_activation_codes_table.php new file mode 100644 index 0000000..5fd4ee5 --- /dev/null +++ b/database/migrations/2026_06_10_000002_create_activation_codes_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('code', 32)->unique(); + $table->foreignId('plan_id')->constrained('membership_plans')->onDelete('cascade'); + $table->foreignId('used_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('used_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('expires_at')->nullable(); + $table->string('batch', 64)->nullable()->index(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('activation_codes'); + } +}; diff --git a/database/migrations/2026_06_10_000003_add_premium_visual_effects_to_users.php b/database/migrations/2026_06_10_000003_add_premium_visual_effects_to_users.php new file mode 100644 index 0000000..29eb2c1 --- /dev/null +++ b/database/migrations/2026_06_10_000003_add_premium_visual_effects_to_users.php @@ -0,0 +1,23 @@ +string('entry_effect', 16)->nullable()->after('profile_bg'); + $table->boolean('animated_banner')->default(false)->after('entry_effect'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['entry_effect', 'animated_banner']); + }); + } +}; diff --git a/database/migrations/2026_06_12_000001_add_is_dubbed_to_animes.php b/database/migrations/2026_06_12_000001_add_is_dubbed_to_animes.php new file mode 100644 index 0000000..8c0a35c --- /dev/null +++ b/database/migrations/2026_06_12_000001_add_is_dubbed_to_animes.php @@ -0,0 +1,21 @@ +boolean('is_dubbed')->default(false)->after('is_featured'); + }); + } + + public function down(): void + { + Schema::table('animes', function (Blueprint $table) { + $table->dropColumn('is_dubbed'); + }); + } +}; diff --git a/database/migrations/2026_06_12_100001_create_voice_calls_table.php b/database/migrations/2026_06_12_100001_create_voice_calls_table.php new file mode 100644 index 0000000..f7cd774 --- /dev/null +++ b/database/migrations/2026_06_12_100001_create_voice_calls_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('caller_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('callee_id')->constrained('users')->cascadeOnDelete(); + $table->string('channel_name')->unique(); + $table->enum('status', ['ringing', 'active', 'ended', 'declined'])->default('ringing'); + $table->timestamp('answered_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamps(); + + $table->index(['callee_id', 'status']); + $table->index(['caller_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('voice_calls'); + } +}; diff --git a/database/migrations/2026_06_16_000001_add_social_auth_to_users.php b/database/migrations/2026_06_16_000001_add_social_auth_to_users.php new file mode 100644 index 0000000..eea0b79 --- /dev/null +++ b/database/migrations/2026_06_16_000001_add_social_auth_to_users.php @@ -0,0 +1,24 @@ +string('social_provider')->nullable()->after('email'); + $table->string('social_id')->nullable()->after('social_provider'); + $table->string('password')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['social_provider', 'social_id']); + $table->string('password')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_06_28_000001_add_is_hevc_to_video_sources.php b/database/migrations/2026_06_28_000001_add_is_hevc_to_video_sources.php new file mode 100644 index 0000000..6265a8f --- /dev/null +++ b/database/migrations/2026_06_28_000001_add_is_hevc_to_video_sources.php @@ -0,0 +1,22 @@ +boolean('is_hevc')->default(false)->after('type'); + $table->timestamp('hevc_checked_at')->nullable()->after('is_hevc'); + }); + } + + public function down(): void + { + Schema::table('video_sources', function (Blueprint $table) { + $table->dropColumn(['is_hevc', 'hevc_checked_at']); + }); + } +}; diff --git a/database/migrations/2026_07_07_000001_create_ads_table.php b/database/migrations/2026_07_07_000001_create_ads_table.php new file mode 100644 index 0000000..24c2659 --- /dev/null +++ b/database/migrations/2026_07_07_000001_create_ads_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('name', 120); // Admin içi tanımlayıcı isim + $table->enum('type', ['video', 'banner'])->default('video'); + $table->string('placement', 30)->default('preroll'); // preroll | home_mid | home_bottom + $table->string('file_path')->nullable(); // Yüklenen dosya (storage/public) + $table->text('external_url')->nullable(); // Dış URL (mp4 veya görsel) + $table->text('click_url')->nullable(); // Tıklama hedefi + $table->unsignedSmallInteger('skip_after')->default(5); // Video: kaç sn sonra geçilebilir + $table->unsignedSmallInteger('weight')->default(10); // Rotasyon ağırlığı (yüksek = sık) + $table->boolean('is_active')->default(true); + $table->timestamp('starts_at')->nullable(); // Zamanlama (opsiyonel) + $table->timestamp('ends_at')->nullable(); + $table->unsignedBigInteger('impressions')->default(0); + $table->unsignedBigInteger('clicks')->default(0); + $table->timestamps(); + + $table->index(['type', 'is_active']); + $table->index('placement'); + }); + } + + public function down(): void + { + Schema::dropIfExists('ads'); + } +}; diff --git a/database/seeders/AchievementSeeder.php b/database/seeders/AchievementSeeder.php new file mode 100644 index 0000000..d1157ca --- /dev/null +++ b/database/seeders/AchievementSeeder.php @@ -0,0 +1,32 @@ +'first_login', 'title'=>'Hoş Geldin!', 'description'=>'İlk kez giriş yaptın', 'icon'=>'bi-door-open', 'color'=>'#79c0ff', 'condition_type'=>'first_login', 'condition_value'=>1], + ['key'=>'ep_1', 'title'=>'İzlemeye Başladım', 'description'=>'İlk bölümü %70 izledin', 'icon'=>'bi-play-circle', 'color'=>'#3fb950', 'condition_type'=>'episodes_watched', 'condition_value'=>1], + ['key'=>'ep_10', 'title'=>'Anime Meraklısı', 'description'=>'10 bölüm izledin', 'icon'=>'bi-collection-play', 'color'=>'#3fb950', 'condition_type'=>'episodes_watched', 'condition_value'=>10], + ['key'=>'ep_50', 'title'=>'Gerçek Otaku', 'description'=>'50 bölüm izledin', 'icon'=>'bi-trophy', 'color'=>'#f0883e', 'condition_type'=>'episodes_watched', 'condition_value'=>50], + ['key'=>'ep_100', 'title'=>'Efsane İzleyici', 'description'=>'100 bölüm izledin', 'icon'=>'bi-trophy-fill', 'color'=>'#ffd700', 'condition_type'=>'episodes_watched', 'condition_value'=>100], + ['key'=>'ep_500', 'title'=>'Ölümsüz Otaku', 'description'=>'500 bölüm izledin', 'icon'=>'bi-stars', 'color'=>'#a371f7', 'condition_type'=>'episodes_watched', 'condition_value'=>500], + ['key'=>'hours_10', 'title'=>'10 Saatlik Mara', 'description'=>'10 saat anime izledin', 'icon'=>'bi-clock-fill', 'color'=>'#79c0ff', 'condition_type'=>'hours_watched', 'condition_value'=>10], + ['key'=>'hours_100', 'title'=>'100 Saat Kulübü', 'description'=>'100 saat anime izledin', 'icon'=>'bi-alarm-fill', 'color'=>'#e84393', 'condition_type'=>'hours_watched', 'condition_value'=>100], + ['key'=>'watchlist_5', 'title'=>'Listeci', 'description'=>'5 anime listeye ekledin', 'icon'=>'bi-bookmark-fill', 'color'=>'#79c0ff', 'condition_type'=>'watchlist_count', 'condition_value'=>5], + ['key'=>'watchlist_20', 'title'=>'Liste Ustası', 'description'=>'20 anime listeye ekledin', 'icon'=>'bi-bookmarks-fill', 'color'=>'#a371f7', 'condition_type'=>'watchlist_count', 'condition_value'=>20], + ['key'=>'rated_1', 'title'=>'Eleştirmen', 'description'=>'İlk anime puanını verdin', 'icon'=>'bi-star-fill', 'color'=>'#ffd700', 'condition_type'=>'anime_rated', 'condition_value'=>1], + ['key'=>'rated_10', 'title'=>'Puan Makinesi', 'description'=>'10 animei puanladın', 'icon'=>'bi-star-half', 'color'=>'#ffd700', 'condition_type'=>'anime_rated', 'condition_value'=>10], + ['key'=>'request_1', 'title'=>'Talep Eden', 'description'=>'Anime isteği gönderdin', 'icon'=>'bi-plus-circle-fill','color'=>'#3fb950', 'condition_type'=>'request_sent', 'condition_value'=>1], + ]; + + foreach ($achievements as $ach) { + Achievement::firstOrCreate(['key' => $ach['key']], $ach); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..1aa02cd --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,56 @@ + 'admin@animexe.com'], + [ + 'name' => 'Admin', + 'password' => Hash::make('admin123'), + 'role' => 'admin', + ] + ); + + // Global izin ayarları + $permissions = [ + ['key' => 'can_watch', 'label' => 'Video İzleme', 'required_membership' => 'free', 'description' => 'Bölümleri izleyebilme'], + ['key' => 'can_comment', 'label' => 'Yorum Yapma', 'required_membership' => 'free', 'description' => 'Anime ve bölümlere yorum yapabilme'], + ['key' => 'can_rate', 'label' => 'Puanlama', 'required_membership' => 'free', 'description' => 'Anime puanlayabilme'], + ['key' => 'can_watchlist', 'label' => 'İzleme Listesi', 'required_membership' => 'free', 'description' => 'İzleme listesine ekleyebilme'], + ['key' => 'watch_hd', 'label' => '1080p İzleme', 'required_membership' => 'premium', 'description' => 'HD kalitede izleyebilme'], + ['key' => 'watch_4k', 'label' => '4K İzleme', 'required_membership' => 'premium', 'description' => '4K kalitede izleyebilme'], + ['key' => 'no_ads', 'label' => 'Reklamsız İzleme', 'required_membership' => 'premium', 'description' => 'Reklam gösterilmez'], + ['key' => 'early_access', 'label' => 'Erken Erişim', 'required_membership' => 'premium', 'description' => 'Yeni bölümlere erken erişim'], + ['key' => 'download', 'label' => 'İndirme', 'required_membership' => 'premium', 'description' => 'Bölümleri indirebilme'], + ]; + + foreach ($permissions as $perm) { + PermissionSetting::updateOrCreate(['key' => $perm['key']], $perm); + } + + // Üyelik planları + $plans = [ + ['name' => 'Aylık Premium', 'slug' => 'aylik-premium', 'price' => 49.99, 'duration_days' => 30, + 'features' => ['Reklamsız izleme', '1080p HD', 'Sınırsız bölüm', 'Erken erişim'], 'is_active' => true, 'sort_order' => 1], + ['name' => '3 Aylık Premium', 'slug' => '3-aylik-premium', 'price' => 129.99, 'duration_days' => 90, + 'features' => ['Reklamsız izleme', '1080p HD', '4K kalite', 'Erken erişim', 'İndirme'], 'is_active' => true, 'sort_order' => 2], + ['name' => 'Yıllık Premium', 'slug' => 'yillik-premium', 'price' => 399.99, 'duration_days' => 365, + 'features' => ['Reklamsız izleme', '1080p HD', '4K kalite', 'Erken erişim', 'İndirme', 'Öncelikli destek'], 'is_active' => true, 'sort_order' => 3], + ]; + + foreach ($plans as $plan) { + MembershipPlan::updateOrCreate(['slug' => $plan['slug']], $plan); + } + } +} diff --git a/env b/env new file mode 100644 index 0000000..2315888 --- /dev/null +++ b/env @@ -0,0 +1,73 @@ +APP_NAME=Animexe +APP_ENV=local +APP_KEY=base64:VZYX91kgHySVDtALn5P4KddvsegF0giHIZboPtLEM+w= +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=mysql +DB_HOST=localhost +DB_PORT=3306 +DB_DATABASE=animexe_new +DB_USERNAME=root +DB_PASSWORD= + +SESSION_DRIVER=file + +BUNNYCDN_STORAGE_ZONE= +BUNNYCDN_API_KEY= +BUNNYCDN_PULL_ZONE= + +DEEPSEEK_API_KEY= + +IMPORT_API_KEY=animexe-import-secret-2024 +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}" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..db9133b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2424 @@ +{ + "name": "animexe", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7686b29 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..bba47c6 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,32 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + + +# php -- BEGIN cPanel-generated handler, do not edit +# Set the “ea-php84” package as the default “PHP” programming language. + + AddHandler application/x-httpd-ea-php84 .php .php8 .phtml + +# php -- END cPanel-generated handler, do not edit diff --git a/public/app/animexe.apk b/public/app/animexe.apk new file mode 100644 index 0000000..8e659bc Binary files /dev/null and b/public/app/animexe.apk differ diff --git a/public/cf-worker/hls-proxy-worker.js b/public/cf-worker/hls-proxy-worker.js new file mode 100644 index 0000000..ef7d64c --- /dev/null +++ b/public/cf-worker/hls-proxy-worker.js @@ -0,0 +1,113 @@ +const WORKER_PATH = '/cf-proxy'; + +export default { + async fetch(request) { + const url = new URL(request.url); + + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + + const uParam = url.searchParams.get('u') || ''; + const refParam = url.searchParams.get('ref') || ''; + + let targetUrl; + try { + targetUrl = atob(uParam); + if (!targetUrl.startsWith('http')) throw 0; + } catch { + return new Response('Missing or invalid u param', { status: 400 }); + } + + let referer; + try { + referer = refParam ? atob(refParam) : new URL(targetUrl).origin + '/'; + if (!referer.endsWith('/')) referer += '/'; + } catch { + referer = new URL(targetUrl).origin + '/'; + } + + let resp; + try { + resp = await fetch(targetUrl, { + headers: { + 'Referer': referer, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'tr-TR,tr;q=0.9,en;q=0.8', + }, + }); + } catch (e) { + return new Response('Upstream fetch failed: ' + e.message, { status: 502 }); + } + + if (!resp.ok && resp.status !== 206) { + // Referer ile de olmadıysa referer'sız dene (bazı CDN'ler Cloudflare'dan gelen Referer'ı reddeder) + try { + const fallbackResp = await fetch(targetUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept': '*/*', + }, + }); + if (fallbackResp.ok || fallbackResp.status === 206) { + resp = fallbackResp; + } + } catch {} + } + + const ct = resp.headers.get('content-type') || ''; + const isM3u8 = ct.includes('mpegurl') || targetUrl.toLowerCase().includes('.m3u8'); + + if (isM3u8) { + const body = await resp.text(); + if (body.trimStart().startsWith('#EXTM3U')) { + const baseDir = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1); + const workerBase = url.origin + WORKER_PATH + '?u='; + const refSuffix = '&ref=' + btoa(referer); + + const rewritten = body.split('\n').map(line => { + const t = line.trimEnd(); + if (t === '') return ''; + if (t.startsWith('#')) { + return t.replace(/URI="([^"]+)"/g, (_, uri) => { + const abs = uri.startsWith('http') ? uri : baseDir + uri; + return `URI="${workerBase}${btoa(abs)}${refSuffix}"`; + }); + } + const abs = t.startsWith('http') ? t : baseDir + t; + return workerBase + btoa(abs) + refSuffix; + }).join('\n'); + + return new Response(rewritten, { + status: 200, + headers: { ...corsHeaders(), 'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-cache' }, + }); + } + // m3u8 gibi görünüp değil — binary olarak geçir + return new Response(body, { + status: resp.status, + headers: { ...corsHeaders(), 'Content-Type': ct || 'video/mp2t' }, + }); + } + + // Segment (.ts, .key, .png vb.) — body'yi doğrudan stream et (belleğe alma) + let segCt = ct; + if (!segCt || segCt.startsWith('image/') || segCt === 'application/octet-stream') { + segCt = 'video/mp2t'; + } + return new Response(resp.body, { + status: resp.status, + headers: { ...corsHeaders(), 'Content-Type': segCt, 'Cache-Control': 'public, max-age=3600' }, + }); + } +}; + +function corsHeaders() { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Range, Origin', + 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Content-Type', + }; +} diff --git a/public/clearcache.php b/public/clearcache.php new file mode 100644 index 0000000..8edcc0a --- /dev/null +++ b/public/clearcache.php @@ -0,0 +1,10 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +\Illuminate\Support\Facades\Artisan::call('config:clear'); +\Illuminate\Support\Facades\Artisan::call('route:clear'); +\Illuminate\Support\Facades\Artisan::call('view:clear'); +\Illuminate\Support\Facades\Artisan::call('cache:clear'); +echo 'Cache cleared. ' . date('H:i:s'); diff --git a/public/css/premium-cosmetics.css b/public/css/premium-cosmetics.css new file mode 100644 index 0000000..5cf04f9 --- /dev/null +++ b/public/css/premium-cosmetics.css @@ -0,0 +1,1511 @@ +/* ═══════════════════════════════════════════════════════════════════ + ANIMEXE — ULTRA PREMIUM COSMETICS SYSTEM v2 + Ultra detailed premium anime streaming UI cosmetics + ═══════════════════════════════════════════════════════════════════ */ + + +/* ═══════════════════════════════════════════════════════════════════ + PERFORMANCE + ACCESSIBILITY + ═══════════════════════════════════════════════════════════════════ */ + +:root { + + --ease-premium: cubic-bezier(.22,.61,.36,1); + + --glow-fire: rgba(255,107,53,.35); + --glow-neon: rgba(0,245,255,.35); + --glow-galaxy: rgba(124,58,237,.35); + --glow-sakura: rgba(255,158,196,.35); + --glow-ice: rgba(168,237,255,.35); + + --premium-shadow: + 0 10px 25px rgba(0,0,0,.28), + 0 2px 8px rgba(0,0,0,.22); + +} + +@media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition: none !important; + scroll-behavior: auto !important; + } + +} + + +/* ═══════════════════════════════════════════════════════════════════ + GLOBAL PREMIUM NOISE OVERLAY + ═══════════════════════════════════════════════════════════════════ */ + +body::before { + + content: ''; + + position: fixed; + inset: 0; + + pointer-events: none; + + z-index: 9999; + + opacity: .018; + + background-image: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'%3E%3Cg fill='white' fill-opacity='1'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3C/g%3E%3C/svg%3E"); + +} + + +/* ═══════════════════════════════════════════════════════════════════ + PREMIUM COMMENT SYSTEM + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg] { + + position: relative; + + overflow: hidden; + + border-radius: 14px; + + padding: 12px 16px; + + isolation: isolate; + + transition: + transform .22s var(--ease-premium), + border-color .22s var(--ease-premium), + box-shadow .22s var(--ease-premium), + background .22s var(--ease-premium); + + will-change: + transform, + box-shadow, + opacity; + + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + +} + +.comment-wrap[data-bg]:hover { + + transform: + translateY(-2px) + scale(1.005); + +} + +.comment-wrap[data-bg] > * { + + position: relative; + z-index: 4; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + PREMIUM BACKGROUND LAYER + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg]::before { + + content: ''; + + position: absolute; + inset: 0; + + z-index: 0; + + border-radius: inherit; + + opacity: .20; + + pointer-events: none; + + background-size: 300% 300%; + + will-change: + background-position, + transform, + opacity; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + PREMIUM PARTICLE LAYER + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg]::after { + + content: ''; + + position: absolute; + inset: 0; + + z-index: 1; + + pointer-events: none; + + opacity: .30; + + mix-blend-mode: screen; + + background-size: 220px 220px; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + FIRE + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="fire"] { + + border: 1px solid rgba(255,107,53,.32); + + box-shadow: + 0 0 20px rgba(255,107,53,.08); + +} + +.comment-wrap[data-bg="fire"]:hover { + + box-shadow: + 0 0 18px rgba(255,107,53,.22), + 0 0 42px rgba(255,45,125,.10); + +} + +.comment-wrap[data-bg="fire"]::before { + + background: + linear-gradient( + 135deg, + #ff6b35, + #ff2d7d, + #ff8c00, + #ff4500 + ); + + animation: bg-fire 3s ease infinite; + +} + +.comment-wrap[data-bg="fire"]::after { + + background-image: + radial-gradient(circle at 20% 30%, rgba(255,180,120,.7) 1px, transparent 1px), + radial-gradient(circle at 80% 60%, rgba(255,80,80,.6) 1px, transparent 1px); + + animation: particleFloat 10s linear infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + AURORA + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="aurora"] { + + border: 1px solid rgba(0,245,255,.28); + + animation: premiumPulse 5s ease-in-out infinite; + +} + +.comment-wrap[data-bg="aurora"]::before { + + background: + linear-gradient( + 135deg, + #00f5b4, + #00f5ff, + #7c3aed, + #00f5b4 + ); + + animation: bg-aurora 6s ease infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + GALAXY + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="galaxy"] { + + border: 1px solid rgba(124,58,237,.35); + + overflow: visible; + + box-shadow: + 0 0 24px rgba(124,58,237,.10); + +} + +.comment-wrap[data-bg="galaxy"]::before { + + background: + radial-gradient( + ellipse at 30% 40%, + rgba(76,29,149,.7) 0%, + transparent 60% + ), + radial-gradient( + ellipse at 70% 60%, + rgba(109,40,217,.6) 0%, + transparent 60% + ), + #0d0d2e; + + filter: blur(14px); + + animation: bg-galaxy 8s ease infinite; + +} + +.comment-wrap[data-bg="galaxy"]::after { + + background-image: + radial-gradient(circle at 20% 20%, rgba(255,255,255,.9) 1px, transparent 1px), + radial-gradient(circle at 70% 60%, rgba(255,255,255,.7) 1px, transparent 1px), + radial-gradient(circle at 40% 80%, rgba(255,255,255,.8) 1px, transparent 1px); + + animation: starsMove 16s linear infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + STARS + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="stars"] { + + border: 1px solid rgba(124,58,237,.25); + +} + +.comment-wrap[data-bg="stars"]::before { + + background: + radial-gradient( + ellipse at 20% 20%, + rgba(124,58,237,.35) 0%, + transparent 50% + ), + radial-gradient( + ellipse at 80% 80%, + rgba(0,245,255,.25) 0%, + transparent 50% + ), + #0a0a2e; + + animation: bg-stars 7s ease infinite; + +} + +.comment-wrap[data-bg="stars"]::after { + + background-image: + radial-gradient(circle at 20% 30%, rgba(255,255,255,.9) 1px, transparent 1px), + radial-gradient(circle at 70% 60%, rgba(255,255,255,.7) 1px, transparent 1px), + radial-gradient(circle at 40% 80%, rgba(255,255,255,.8) 1px, transparent 1px); + + animation: starsMove 20s linear infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + NEON + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="neon"] { + + border: 1px solid rgba(0,245,255,.32); + + box-shadow: + 0 0 14px rgba(0,245,255,.10); + +} + +.comment-wrap[data-bg="neon"]:hover { + + box-shadow: + 0 0 22px rgba(0,245,255,.22), + 0 0 40px rgba(184,77,255,.14); + +} + +.comment-wrap[data-bg="neon"]::before { + + background: + linear-gradient( + 135deg, + rgba(0,245,255,.6), + rgba(184,77,255,.5), + rgba(0,245,255,.35) + ); + + box-shadow: + inset 0 0 60px rgba(0,245,255,.10); + + animation: bg-neon 2.5s ease infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + ICE + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="ice"] { + + border: 1px solid rgba(168,237,255,.35); + +} + +.comment-wrap[data-bg="ice"]::before { + + background: + linear-gradient( + 135deg, + rgba(168,237,255,.55), + rgba(96,207,255,.45), + rgba(224,247,255,.55) + ); + + animation: bg-ice 5s ease infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + SAKURA + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-bg="sakura"] { + + border: 1px solid rgba(255,158,196,.35); + +} + +.comment-wrap[data-bg="sakura"]::before { + + background: + linear-gradient( + 135deg, + rgba(255,158,196,.7), + rgba(255,214,231,.45), + rgba(255,158,196,.6) + ); + + animation: bg-sakura 4s ease infinite; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + ULTRA RARE / LEGENDARY SYSTEM + ═══════════════════════════════════════════════════════════════════ */ + +[data-rarity="legendary"] { + + position: relative; + +} + +[data-rarity="legendary"]::before { + + content: ''; + + position: absolute; + inset: -1px; + + border-radius: inherit; + + padding: 1px; + + background: + linear-gradient( + 135deg, + #ffd700, + #ff6b00, + #ff0080, + #ffd700 + ); + + background-size: 300% 300%; + + animation: legendaryBorder 4s linear infinite; + + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + + -webkit-mask-composite: xor; + + pointer-events: none; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + USERNAME COLORS + ═══════════════════════════════════════════════════════════════════ */ + +[class*="username-color-"] { + + display: inline; + + background-size: 200% auto; + + -webkit-background-clip: text; + background-clip: text; + + /* color:transparent yerine -webkit-text-fill-color kullanmıyoruz — + flex item içinde compositing layer oluşturursa background-clip bozulur. + will-change da kaldırıldı aynı sebepten. */ + color: transparent; + + font-weight: 800; + + animation: usernameFlow 5s linear infinite; + +} + +.username-color-fire { + background-image: linear-gradient(90deg,#ff6b35,#ff2d7d); +} + +.username-color-aurora { + background-image: linear-gradient(90deg,#00f5b4,#00f5ff); +} + +.username-color-sakura { + background-image: linear-gradient(90deg,#ff9ec4,#ff2d7d); +} + +.username-color-neon { + background-image: linear-gradient(90deg,#00f5ff,#b84dff); +} + +.username-color-galaxy { + background-image: linear-gradient(90deg,#7c3aed,#b84dff); +} + +.username-color-gold { + background-image: linear-gradient(90deg,#ffd700,#ff8c00); +} + +.username-color-ice { + background-image: linear-gradient(90deg,#a8edff,#60cfff); +} + +.username-color-blood { + background-image: linear-gradient(90deg,#8b0000,#dc143c); +} + +.username-color-rainbow { + background-image: + linear-gradient( + 90deg, + #ff0000, + #ff8c00, + #ffd700, + #00c800, + #0088ff, + #8b00ff, + #ff0080 + ); +} + + +/* ═══════════════════════════════════════════════════════════════════ + PREMIUM AVATAR FRAMES + ═══════════════════════════════════════════════════════════════════ */ + +.avatar-wrap { + + position: relative; + + display: inline-block; + +} + +.avatar-wrap .avatar-frame { + + position: absolute; + + inset: -4px; + + border-radius: 50%; + + animation: frame-spin 4s linear infinite; + + pointer-events: none; + + z-index: 2; + + filter: + drop-shadow(0 0 6px rgba(255,255,255,.08)) + drop-shadow(0 0 16px rgba(255,255,255,.12)); + + will-change: + transform; + +} + +.avatar-wrap .avatar-frame::after { + + content: ''; + + position: absolute; + + inset: 3px; + + border-radius: 50%; + + background: + var(--body-bg,#09090f); + +} + +.avatar-wrap img { + + position: relative; + + z-index: 3; + +} + + +/* FRAME THEMES */ + +.frame-fire { + + background: + conic-gradient( + #ff6b35, + #ff2d7d, + #ff8c00, + #ff6b35 + ); + + box-shadow: + 0 0 14px rgba(255,107,53,.30), + 0 0 30px rgba(255,45,125,.14); + +} + +.frame-neon { + + background: + conic-gradient( + #00f5ff, + #b84dff, + #00f5ff + ); + +} + +.frame-galaxy { + + background: + conic-gradient( + #7c3aed, + #b84dff, + #4a1d96, + #7c3aed + ); + + box-shadow: + 0 0 16px rgba(124,58,237,.22), + 0 0 32px rgba(184,77,255,.10); + +} + +.frame-rainbow { + + background: + conic-gradient( + #ff0000, + #ff8c00, + #ffd700, + #00c800, + #0088ff, + #8b00ff, + #ff0080, + #ff0000 + ); + +} + + +/* ═══════════════════════════════════════════════════════════════════ + PREMIUM BADGES + ═══════════════════════════════════════════════════════════════════ */ + +.premium-badge { + + display: inline-flex; + + align-items: center; + + gap: 4px; + + font-size: .68rem; + + font-weight: 800; + + padding: 3px 8px; + + border-radius: 999px; + + letter-spacing: .03em; + + position: relative; + + overflow: hidden; + + backdrop-filter: blur(10px); + + -webkit-backdrop-filter: blur(10px); + +} + +.premium-badge::before { + + content: ''; + + position: absolute; + inset: 0; + + background: + linear-gradient( + 180deg, + rgba(255,255,255,.10), + transparent + ); + +} + +.premium-badge-default { + + background: + linear-gradient( + 90deg, + rgba(0,245,255,.12), + rgba(184,77,255,.12) + ); + + border: + 1px solid rgba(0,245,255,.30); + + color: + #00f5ff; + +} + +/* Custom user badge (profile_badge) shown in comments */ +.user-badge-custom { + + background: + linear-gradient( + 90deg, + rgba(255,215,0,.14), + rgba(255,140,0,.10) + ); + + border: + 1px solid rgba(255,215,0,.28); + + color: + #ffd700; + + font-size: .62rem; + + animation: badgeGlow 3s ease-in-out infinite; + +} + +@keyframes badgeGlow { + + 0%,100% { box-shadow: none; } + 50% { box-shadow: 0 0 8px rgba(255,215,0,.25); } + +} + + +/* ═══════════════════════════════════════════════════════════════════ + PREMIUM PICKER + ═══════════════════════════════════════════════════════════════════ */ + +.prem-picker { + + display: grid; + + gap: 10px; + + margin-top: 12px; + +} + +.prem-pick-item { + + position: relative; + + display: flex; + + flex-direction: column; + + align-items: center; + + justify-content: center; + + gap: 8px; + + padding: 14px 10px; + + border-radius: 16px; + + cursor: pointer; + + overflow: hidden; + + border: + 1px solid rgba(255,255,255,.08); + + background: + rgba(255,255,255,.03); + + transition: + border-color .18s ease, + background .18s ease, + transform .18s ease; + + backdrop-filter: blur(14px); + + -webkit-backdrop-filter: blur(14px); + +} + +.prem-pick-item::before { + + content: ''; + + position: absolute; + + inset: 0; + + border-radius: inherit; + + background: + linear-gradient( + 180deg, + rgba(255,255,255,.08), + transparent + ); + +} + +.prem-pick-item:hover { + + transform: + translateY(-2px); + + border-color: + rgba(255,255,255,.20); + + background: + rgba(255,255,255,.06); + +} + +.prem-pick-item.active { + + border-color: + rgba(0,245,255,.55) !important; + + background: + rgba(0,245,255,.08) !important; + + box-shadow: + 0 0 24px rgba(0,245,255,.12); + +} + +.prem-pick-item input[type="radio"] { + + display: none; + +} + +.prem-pick-preview { + + font-size: .92rem; + + font-weight: 800; + +} + +.prem-pick-lbl { + + font-size: .66rem; + + color: rgba(255,255,255,.42); + +} + + +/* ═══════════════════════════════════════════════════════════════════ + PROFILE BACKGROUNDS + ═══════════════════════════════════════════════════════════════════ */ + +.profile-bg-fire { + + background: + linear-gradient( + 135deg, + #0d0409, + #1a0510, + #0d0409 + ) !important; + +} + +.profile-bg-galaxy { + + background: + radial-gradient( + ellipse at top, + #0d0520 0%, + #050510 60% + ) !important; + +} + +.profile-bg-aurora { + + background: + linear-gradient( + 135deg, + #040e0e, + #071a1a, + #040e0e + ) !important; + +} + +.profile-bg-ice { + + background: + linear-gradient( + 135deg, + #040d11, + #071420, + #040d11 + ) !important; + +} + + +/* ═══════════════════════════════════════════════════════════════════ + THEMES + ═══════════════════════════════════════════════════════════════════ */ + +[data-theme="fire"] { + --accent:#ff6b35; + --accent2:#ff2d7d; +} + +[data-theme="galaxy"] { + --accent:#b84dff; + --accent2:#7c3aed; +} + +[data-theme="aurora"] { + --accent:#00f5b4; + --accent2:#00f5ff; +} + +[data-theme="ice"] { + --accent:#60cfff; + --accent2:#a8edff; +} + + +/* ═══════════════════════════════════════════════════════════════════ + ANIMATIONS + ═══════════════════════════════════════════════════════════════════ */ + +@keyframes bg-fire { + + 0% { + background-position: 0% 50%; + opacity: .18; + } + + 50% { + background-position: 100% 50%; + opacity: .30; + } + + 100% { + background-position: 0% 50%; + opacity: .18; + } + +} + +@keyframes bg-aurora { + + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } + +} + +@keyframes bg-neon { + + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } + +} + +@keyframes bg-stars { + + 0%,100% { + opacity: .22; + } + + 50% { + opacity: .34; + } + +} + +@keyframes bg-galaxy { + + 0% { + transform: rotate(0deg) scale(1); + } + + 50% { + transform: rotate(3deg) scale(1.02); + } + + 100% { + transform: rotate(0deg) scale(1); + } + +} + +@keyframes bg-sakura { + + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } + +} + +@keyframes bg-ice { + + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } + +} + +@keyframes frame-spin { + + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + +} + +@keyframes usernameFlow { + + from { + background-position: 0% center; + } + + to { + background-position: 220% center; + } + +} + +@keyframes premiumPulse { + + 0%,100% { + transform: scale(1); + } + + 50% { + transform: scale(1.01); + } + +} + +@keyframes particleFloat { + + from { + transform: translateY(0); + } + + to { + transform: translateY(-35px); + } + +} + +@keyframes starsMove { + + from { + transform: translateY(0); + } + + to { + transform: translateY(-28px); + } + +} + +@keyframes legendaryBorder { + + 0% { + background-position: 0% 50%; + } + + 100% { + background-position: 300% 50%; + } + +} + + +/* ═══════════════════════════════════════════════════════════════════ + COMMENT GLOW AURAS + ═══════════════════════════════════════════════════════════════════ */ + +.comment-wrap[data-glow] { + transition: + box-shadow .3s var(--ease-premium), + transform .22s var(--ease-premium); +} + +.comment-wrap[data-glow="cyan"] { + box-shadow: + 0 0 0 1.5px rgba(0,245,255,.30), + 0 0 18px rgba(0,245,255,.14), + 0 0 36px rgba(0,245,255,.06); +} + +.comment-wrap[data-glow="cyan"]:hover { + box-shadow: + 0 0 0 1.5px rgba(0,245,255,.55), + 0 0 28px rgba(0,245,255,.25), + 0 0 56px rgba(0,245,255,.10); +} + +.comment-wrap[data-glow="pink"] { + box-shadow: + 0 0 0 1.5px rgba(255,45,125,.30), + 0 0 18px rgba(255,45,125,.14), + 0 0 36px rgba(255,45,125,.06); +} + +.comment-wrap[data-glow="pink"]:hover { + box-shadow: + 0 0 0 1.5px rgba(255,45,125,.55), + 0 0 28px rgba(255,45,125,.25), + 0 0 56px rgba(255,45,125,.10); +} + +.comment-wrap[data-glow="gold"] { + box-shadow: + 0 0 0 1.5px rgba(255,215,0,.30), + 0 0 18px rgba(255,215,0,.14), + 0 0 36px rgba(255,215,0,.06); +} + +.comment-wrap[data-glow="gold"]:hover { + box-shadow: + 0 0 0 1.5px rgba(255,215,0,.55), + 0 0 28px rgba(255,215,0,.25), + 0 0 56px rgba(255,215,0,.10); +} + +.comment-wrap[data-glow="green"] { + box-shadow: + 0 0 0 1.5px rgba(0,245,100,.30), + 0 0 18px rgba(0,245,100,.14), + 0 0 36px rgba(0,245,100,.06); +} + +.comment-wrap[data-glow="green"]:hover { + box-shadow: + 0 0 0 1.5px rgba(0,245,100,.55), + 0 0 28px rgba(0,245,100,.25), + 0 0 56px rgba(0,245,100,.10); +} + +.comment-wrap[data-glow="purple"] { + box-shadow: + 0 0 0 1.5px rgba(184,77,255,.30), + 0 0 18px rgba(184,77,255,.14), + 0 0 36px rgba(184,77,255,.06); +} + +.comment-wrap[data-glow="purple"]:hover { + box-shadow: + 0 0 0 1.5px rgba(184,77,255,.55), + 0 0 28px rgba(184,77,255,.25), + 0 0 56px rgba(184,77,255,.10); +} + +.comment-wrap[data-glow="fire"] { + box-shadow: + 0 0 0 1.5px rgba(255,107,53,.30), + 0 0 18px rgba(255,107,53,.14), + 0 0 36px rgba(255,107,53,.06); +} + +.comment-wrap[data-glow="fire"]:hover { + box-shadow: + 0 0 0 1.5px rgba(255,107,53,.55), + 0 0 28px rgba(255,107,53,.25), + 0 0 56px rgba(255,107,53,.10); +} + + +/* ═══════════════════════════════════════════════════════════════════ + USERNAME ANIMATION EFFECTS + ═══════════════════════════════════════════════════════════════════ */ + +.username-effect-shimmer { + background: + linear-gradient( + 90deg, + rgba(255,255,255,.55) 0%, + rgba(255,255,255,1) 30%, + rgba(255,255,255,.55) 60%, + rgba(255,255,255,1) 85%, + rgba(255,255,255,.55) 100% + ); + background-size: 250% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent !important; + font-weight: 800; + animation: shimmerFlow 2.2s linear infinite; +} + +.username-effect-wave { + display: inline-block; + background: + linear-gradient( + 90deg, + #00f5ff, + #b84dff, + #ff2d7d, + #00f5ff + ); + background-size: 300% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent !important; + font-weight: 800; + animation: waveColorFlow 4s ease-in-out infinite; +} + +.username-effect-pulse { + display: inline-block; + font-weight: 800; + animation: usernamePulseGlow 2s ease-in-out infinite; +} + +.username-effect-glitch { + display: inline-block; + font-weight: 800; + position: relative; + animation: usernameGlitchShake 5s ease-in-out infinite; +} + +.username-effect-glitch::before { + content: attr(data-text); + position: absolute; + left: 2px; + top: 0; + color: #00f5ff; + opacity: .7; + clip-path: inset(0 0 60% 0); + animation: glitchClipTop 5s ease-in-out infinite; +} + +.username-effect-glitch::after { + content: attr(data-text); + position: absolute; + left: -2px; + top: 0; + color: #ff2d7d; + opacity: .7; + clip-path: inset(60% 0 0 0); + animation: glitchClipBot 5s ease-in-out infinite; +} + +.username-effect-bounce { + display: inline-block; + font-weight: 800; + animation: usernameBounce 1.8s ease-in-out infinite; +} + +@keyframes shimmerFlow { + from { background-position: 0% center; } + to { background-position: 250% center; } +} + +@keyframes waveColorFlow { + 0%,100% { background-position: 0% center; } + 50% { background-position: 150% center; } +} + +@keyframes usernamePulseGlow { + 0%,100% { text-shadow: 0 0 6px rgba(0,245,255,.5), 0 0 16px rgba(0,245,255,.3); } + 50% { text-shadow: 0 0 14px rgba(184,77,255,.8), 0 0 32px rgba(184,77,255,.4); } +} + +@keyframes usernameGlitchShake { + 0%,90%,100% { transform: translateX(0); } + 92% { transform: translateX(-3px); } + 94% { transform: translateX(3px); } + 96% { transform: translateX(-2px); } + 98% { transform: translateX(2px); } +} + +@keyframes glitchClipTop { + 0%,88%,100% { clip-path: inset(0 0 60% 0); transform: translateX(0); } + 90% { clip-path: inset(10% 0 50% 0); transform: translateX(4px); } + 92% { clip-path: inset(0 0 65% 0); transform: translateX(-4px); } +} + +@keyframes glitchClipBot { + 0%,88%,100% { clip-path: inset(60% 0 0 0); transform: translateX(0); } + 90% { clip-path: inset(55% 0 5% 0); transform: translateX(-4px); } + 92% { clip-path: inset(65% 0 0 0); transform: translateX(4px); } +} + +@keyframes usernameBounce { + 0%,100% { transform: translateY(0); } + 30% { transform: translateY(-5px); } + 60% { transform: translateY(-2px); } +} + + +/* ═══════════════════════════════════════════════════════════════════ + COMMENT SIGNATURE + ═══════════════════════════════════════════════════════════════════ */ + +.comment-signature { + display: block; + margin-top: 7px; + padding-top: 7px; + border-top: 1px solid rgba(255,255,255,.06); + font-size: .68rem; + color: rgba(255,255,255,.32); + font-style: italic; + letter-spacing: .03em; + line-height: 1.4; +} + +.comment-wrap[data-glow="cyan"] .comment-signature { color: rgba(0,245,255,.45); } +.comment-wrap[data-glow="pink"] .comment-signature { color: rgba(255,45,125,.45); } +.comment-wrap[data-glow="gold"] .comment-signature { color: rgba(255,215,0,.45); } +.comment-wrap[data-glow="green"] .comment-signature { color: rgba(0,245,100,.45); } +.comment-wrap[data-glow="purple"] .comment-signature{ color: rgba(184,77,255,.45); } +.comment-wrap[data-glow="fire"] .comment-signature { color: rgba(255,107,53,.45); } + + +/* ═══════════════════════════════════════════════════════════════════ + PAGE ENTRY EFFECTS — applied as body.entry-{name} + ═══════════════════════════════════════════════════════════════════ */ + +body.entry-fade > #page-content, +body.entry-slide > #page-content, +body.entry-zoom > #page-content, +body.entry-glitch > #page-content, +body.entry-wave > #page-content { + animation-duration: .65s; + animation-fill-mode: both; + animation-timing-function: var(--ease-premium); +} + +body.entry-fade > #page-content { animation-name: entryFadeIn; } +body.entry-slide > #page-content { animation-name: entrySlideUp; } +body.entry-zoom > #page-content { animation-name: entryZoomIn; } +body.entry-glitch > #page-content{ animation-name: entryGlitchIn; } +body.entry-wave > #page-content { animation-name: entryWaveIn; } + +@keyframes entryFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes entrySlideUp { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes entryZoomIn { + from { opacity: 0; transform: scale(.96); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes entryGlitchIn { + 0% { opacity: 0; clip-path: inset(0 100% 0 0); } + 40% { opacity: 1; clip-path: inset(0 8% 0 0); } + 55% { clip-path: inset(0 0 0 5%); } + 70% { clip-path: inset(0 4% 0 0); } + 100%{ clip-path: none; } +} + +@keyframes entryWaveIn { + 0% { opacity: 0; transform: translateY(32px) scaleX(.95); } + 60% { opacity: 1; transform: translateY(-4px) scaleX(1.01); } + 100%{ opacity: 1; transform: translateY(0) scaleX(1); } +} + + +/* ═══════════════════════════════════════════════════════════════════ + ANIMATED BANNER + ═══════════════════════════════════════════════════════════════════ */ + +.ps-banner-area.animated-banner, +.profile-banner-wrap.animated-banner { + isolation: isolate; +} + +.ps-banner-area.animated-banner::before, +.profile-banner-wrap.animated-banner::before { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + background: + radial-gradient(circle at 20% 50%, rgba(0,245,255,.14) 0%, transparent 45%), + radial-gradient(circle at 80% 50%, rgba(184,77,255,.14) 0%, transparent 45%); + animation: bannerOrbs 6s ease-in-out infinite; +} + +.ps-banner-area.animated-banner::after, +.profile-banner-wrap.animated-banner::after { + content: ''; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + background: + linear-gradient( + 90deg, + transparent 0%, + rgba(0,245,255,.06) 40%, + rgba(184,77,255,.06) 60%, + transparent 100% + ); + background-size: 300% 100%; + animation: bannerShimmer 5s linear infinite; +} + +@keyframes bannerOrbs { + 0%,100% { opacity: .6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } +} + +@keyframes bannerShimmer { + from { background-position: 200% center; } + to { background-position: -200% center; } +} + + +/* ═══════════════════════════════════════════════════════════════════ + PROFILE BACKGROUND EXTRAS (sakura, neon, stars) + ═══════════════════════════════════════════════════════════════════ */ + +.profile-bg-sakura { + background: + linear-gradient( + 135deg, + #180810, + #28101c, + #180810 + ) !important; +} + +.profile-bg-neon { + background: + linear-gradient( + 135deg, + #030d14, + #071820, + #030d14 + ) !important; +} + +.profile-bg-stars { + background: + radial-gradient( + ellipse at 30% 20%, + #0e0530 0%, + #050518 60% + ) !important; +} \ No newline at end of file diff --git a/public/error_log b/public/error_log new file mode 100644 index 0000000..f34f088 --- /dev/null +++ b/public/error_log @@ -0,0 +1,13 @@ +[17-Apr-2026 03:26:39 UTC] PHP Parse error: syntax error, unexpected token "`" in /home/karaonth/animexe.com/public/migrate_run.php on line 136 +[17-Apr-2026 03:26:40 UTC] PHP Parse error: syntax error, unexpected token "`" in /home/karaonth/animexe.com/public/migrate_run.php on line 136 +[17-Apr-2026 03:27:21 UTC] PHP Parse error: syntax error, unexpected token "`" in /home/karaonth/animexe.com/public/migrate_run.php on line 138 +[17-Apr-2026 03:27:22 UTC] PHP Parse error: syntax error, unexpected token "`" in /home/karaonth/animexe.com/public/migrate_run.php on line 138 +[17-Apr-2026 11:19:53 UTC] PHP Fatal error: Uncaught Error: Class "Artisan" not found in /home/karaonth/animexe.com/public/clearcache.php:5 +Stack trace: +#0 {main} + thrown in /home/karaonth/animexe.com/public/clearcache.php on line 5 +[17-Apr-2026 11:19:54 UTC] PHP Fatal error: Uncaught Error: Class "Artisan" not found in /home/karaonth/animexe.com/public/clearcache.php:5 +Stack trace: +#0 {main} + thrown in /home/karaonth/animexe.com/public/clearcache.php on line 5 +[28-Apr-2026 20:58:12 UTC] PHP Warning: include(/home/karaonth/animexe.com/vendor/laravel/framework/src/Illuminate/Collections/Arr.php): Failed to open stream: No such file or directory in /home/karaonth/animexe.com/vendor/composer/ClassLoader.php on line 576 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..926ca37 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/hls-proxy.php b/public/hls-proxy.php new file mode 100644 index 0000000..c621e96 --- /dev/null +++ b/public/hls-proxy.php @@ -0,0 +1,154 @@ + true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_ENCODING => '', // gzip/br otomatik çöz + ]); + $raw = curl_exec($ch); + $info = [ + 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE), + 'hsize' => curl_getinfo($ch, CURLINFO_HEADER_SIZE), + 'ctype' => curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '', + 'final' => curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), + 'err' => curl_error($ch), + ]; + curl_close($ch); + return [$raw, $info]; +}; + +$UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +$baseHeaders = [ + 'User-Agent: ' . $UA, + 'Accept: */*', + 'Accept-Language: tr-TR,tr;q=0.9,en;q=0.8', + 'Accept-Encoding: identity', + 'Referer: ' . $referer, + 'Origin: ' . $origin, + 'Sec-Fetch-Dest: empty', + 'Sec-Fetch-Mode: cors', + 'Sec-Fetch-Site: cross-site', +]; +if (!empty($_SERVER['HTTP_RANGE'])) { + $baseHeaders[] = 'Range: ' . $_SERVER['HTTP_RANGE']; +} + +[$raw, $info] = $doFetch($url, $baseHeaders); + +// 403/401 → hotlink koruması olabilir; Origin'siz ve sadece Referer ile bir kez daha dene +if (in_array($info['code'], [401, 403], true)) { + $retryHeaders = array_values(array_filter($baseHeaders, function ($h) { + return !preg_match('/^(Origin|Sec-Fetch-):/i', $h); + })); + [$raw2, $info2] = $doFetch($url, $retryHeaders); + if (!in_array($info2['code'], [401, 403], true) && $raw2 !== false) { + $raw = $raw2; $info = $info2; + } +} + +$httpCode = $info['code']; +$hdrSize = $info['hsize']; +$ctype = $info['ctype']; +$finalUrl = $info['final']; +$curlErr = $info['err']; + +if ($raw === false || $curlErr) { http_response_code(502); die('Upstream hatası: ' . $curlErr); } + +$body = substr($raw, $hdrSize); + +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, OPTIONS'); +header('Access-Control-Allow-Headers: Range'); +header('Access-Control-Expose-Headers: Content-Length, Content-Range, Content-Type'); +http_response_code($httpCode); + +$isM3u8 = str_contains($ctype, 'mpegurl') + || preg_match('/\.m3u8(\?|$)/i', strtok($url, '#')); + +if ($isM3u8 && str_starts_with(ltrim($body), '#EXTM3U')) { + header('Content-Type: application/vnd.apple.mpegurl'); + header('Cache-Control: no-cache'); + + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $proxyBase = $scheme . '://' . $_SERVER['HTTP_HOST'] . '/hls-proxy.php?ref=' . urlencode($referer) . '&url='; + $baseDir = dirname($finalUrl) . '/'; + + $output = []; + foreach (explode("\n", $body) as $line) { + $line = rtrim($line, "\r"); + if ($line === '') { $output[] = ''; continue; } + + if (str_starts_with($line, '#')) { + $line = preg_replace_callback('/URI="([^"]+)"/', function ($m) use ($baseDir, $proxyBase) { + $uri = $m[1]; + if (!str_starts_with($uri, 'http')) $uri = $baseDir . $uri; + return 'URI="' . $proxyBase . urlencode($uri) . '"'; + }, $line); + $output[] = $line; + } else { + if (!str_starts_with($line, 'http')) $line = $baseDir . $line; + $output[] = $proxyBase . urlencode($line); + } + } + echo implode("\n", $output); +} else { + if ($ctype) header('Content-Type: ' . $ctype); + $len = strlen($body); + if ($len) header('Content-Length: ' . $len); + echo $body; +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/logo.jpg b/public/logo.jpg new file mode 100644 index 0000000..38c152c Binary files /dev/null and b/public/logo.jpg differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..45e9b1f --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "Animexe", + "short_name": "Animexe", + "description": "Türkçe anime izle — ücretsiz HD kalitede", + "start_url": "/", + "display": "standalone", + "background_color": "#06060d", + "theme_color": "#06060d", + "orientation": "any", + "lang": "tr", + "scope": "/", + "icons": [ + { + "src": "/logo.jpg", + "sizes": "192x192", + "type": "image/jpeg", + "purpose": "any maskable" + }, + { + "src": "/logo.jpg", + "sizes": "512x512", + "type": "image/jpeg", + "purpose": "any maskable" + } + ], + "categories": ["entertainment"], + "screenshots": [], + "shortcuts": [ + { + "name": "Ara", + "short_name": "Ara", + "description": "Anime ara", + "url": "/search", + "icons": [{ "src": "/logo.jpg", "sizes": "96x96" }] + }, + { + "name": "AI Hub", + "short_name": "AI", + "description": "AI anime asistanı", + "url": "/ai", + "icons": [{ "src": "/logo.jpg", "sizes": "96x96" }] + } + ] +} diff --git a/public/migrate_run.php b/public/migrate_run.php new file mode 100644 index 0000000..9e48926 --- /dev/null +++ b/public/migrate_run.php @@ -0,0 +1,724 @@ +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +echo '
    ';
    +echo "=== Animexe Migration & Seed ===\n\n";
    +
    +$db  = app('db');
    +$sch = app('db')->getSchemaBuilder();
    +$batch = ($db->table('migrations')->max('batch') ?? 0) + 1;
    +
    +// Üretimde zaten uygulanmış ama migrations tablosuna kayıt edilmemiş migration'ları işaretle
    +// [ migration_name => kontrol fonksiyonu (true = zaten uygulanmış) ]
    +$alreadyApplied = [
    +    '2024_01_01_000150_add_trending_to_animes' => fn() =>
    +        $sch->hasColumn('animes', 'is_trending'),
    +
    +    '2024_01_01_000200_add_profile_fields_to_users' => fn() =>
    +        $sch->hasColumn('users', 'bio'),
    +
    +    '2026_04_17_000001_create_blog_posts_table' => fn() =>
    +        $sch->hasTable('blog_posts'),
    +
    +    '2024_01_01_000200_add_intro_times_to_episodes' => fn() =>
    +        $sch->hasColumn('episodes', 'intro_start'),
    +
    +    '2026_04_17_200001_create_social_features_tables' => fn() =>
    +        $sch->hasTable('episode_timestamp_comments'),
    +
    +    '2026_04_17_300000_create_community_features_tables' => fn() =>
    +        $sch->hasTable('tribunals'),
    +
    +    '2026_04_17_310000_add_extra_sides_to_tribunals' => fn() =>
    +        $sch->hasColumn('tribunals', 'extra_sides'),
    +
    +    '2026_04_18_000001_create_bot_protection_tables' => fn() =>
    +        $sch->hasTable('analytics_bot_logs'),
    +
    +    '2026_04_19_000001_create_moderator_permissions_table' => fn() =>
    +        $sch->hasTable('moderator_permissions') && $sch->hasTable('user_activity_logs'),
    +
    +    '2026_04_19_000002_add_bot_columns_to_analytics_pageviews' => fn() =>
    +        $sch->hasColumn('analytics_pageviews', 'is_bot'),
    +
    +    '2026_04_19_000010_create_seo_keyword_tracker_table' => fn() =>
    +        $sch->hasTable('seo_keyword_tracker'),
    +
    +    '2026_04_19_000011_create_seo_redirects_table' => fn() =>
    +        $sch->hasTable('seo_redirects'),
    +
    +    '2026_04_19_000012_add_seo_columns_to_animes' => fn() =>
    +        $sch->hasColumn('animes', 'seo_title'),
    +];
    +
    +// Kolon yoksa direkt SQL ile ekle (artisan fallback)
    +if (!$sch->hasColumn('episodes', 'intro_start')) {
    +    echo "▶ intro_start / intro_end kolonları ekleniyor...\n";
    +    try {
    +        $db->statement("ALTER TABLE `episodes` ADD COLUMN `intro_start` SMALLINT UNSIGNED NULL DEFAULT NULL AFTER `duration`, ADD COLUMN `intro_end` SMALLINT UNSIGNED NULL DEFAULT NULL AFTER `intro_start`");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2024_01_01_000200_add_intro_times_to_episodes', 'batch' => $batch]);
    +        echo "✓ Kolonlar eklendi\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ intro_start / intro_end kolonları zaten mevcut\n\n";
    +}
    +
    +foreach ($alreadyApplied as $migration => $check) {
    +    $alreadyInDb = $db->table('migrations')->where('migration', $migration)->exists();
    +    if ($alreadyInDb) {
    +        echo "✓ Kayıtlı: $migration\n";
    +        continue;
    +    }
    +    if ($check()) {
    +        $db->table('migrations')->insert(['migration' => $migration, 'batch' => $batch]);
    +        echo "⚡ Zaten uygulanmış, atlandı: $migration\n";
    +    } else {
    +        echo "⏳ Beklemede (migrate ile çalışacak): $migration\n";
    +    }
    +}
    +echo "\n";
    +
    +// Kritik: blog_posts tablosu yoksa direkt SQL ile oluştur (migration başarısız olsa bile)
    +if (!$sch->hasTable('blog_posts')) {
    +    echo "▶ blog_posts tablosu oluşturuluyor (fallback)...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `blog_posts` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `title` varchar(255) NOT NULL,
    +            `slug` varchar(255) NOT NULL UNIQUE,
    +            `excerpt` text,
    +            `content` longtext,
    +            `cover_image` varchar(255) DEFAULT NULL,
    +            `focus_keyword` varchar(255) DEFAULT NULL,
    +            `meta_title` varchar(255) DEFAULT NULL,
    +            `meta_description` text,
    +            `meta_keywords` varchar(255) DEFAULT NULL,
    +            `status` enum('draft','published','generating') NOT NULL DEFAULT 'draft',
    +            `ai_generated` tinyint(1) NOT NULL DEFAULT 0,
    +            `anime_id` bigint unsigned DEFAULT NULL,
    +            `linked_anime_ids` json DEFAULT NULL,
    +            `faq` json DEFAULT NULL,
    +            `views` int unsigned NOT NULL DEFAULT 0,
    +            `reading_time` smallint unsigned NOT NULL DEFAULT 5,
    +            `published_at` timestamp NULL DEFAULT NULL,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +        // migrations tablosuna kaydet
    +        $db->table('migrations')->insert(['migration' => '2026_04_17_000001_create_blog_posts_table', 'batch' => $batch]);
    +        echo "✓ blog_posts tablosu oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ blog_posts fallback HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ blog_posts tablosu zaten mevcut\n\n";
    +}
    +
    +// Sosyal özellikler tablolarını oluştur (fallback)
    +if (!$sch->hasTable('episode_timestamp_comments')) {
    +    echo "▶ Sosyal özellikler tabloları oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `episode_timestamp_comments` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned DEFAULT NULL,
    +            `timestamp_sec` smallint unsigned NOT NULL,
    +            `body` varchar(100) NOT NULL,
    +            `color` varchar(7) NOT NULL DEFAULT '#ffffff',
    +            `is_hidden` tinyint(1) NOT NULL DEFAULT 0,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_ep_ts` (`episode_id`, `timestamp_sec`),
    +            KEY `idx_user` (`user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `episode_predictions` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `body` varchar(280) NOT NULL,
    +            `is_correct` tinyint(1) DEFAULT NULL,
    +            `vote_count` smallint unsigned NOT NULL DEFAULT 0,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_ep_user` (`episode_id`, `user_id`),
    +            KEY `idx_ep_votes` (`episode_id`, `vote_count`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `prediction_votes` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `prediction_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_pred_user` (`prediction_id`, `user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `watch_parties` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `room_code` varchar(10) NOT NULL,
    +            `host_user_id` bigint unsigned NOT NULL,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `current_sec` int unsigned NOT NULL DEFAULT 0,
    +            `is_playing` tinyint(1) NOT NULL DEFAULT 0,
    +            `synced_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    +            `max_members` tinyint unsigned NOT NULL DEFAULT 10,
    +            `is_private` tinyint(1) NOT NULL DEFAULT 0,
    +            `password` varchar(60) DEFAULT NULL,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_code` (`room_code`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `watch_party_members` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `party_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `joined_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            `last_ping` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_party_user` (`party_id`, `user_id`),
    +            KEY `idx_party_ping` (`party_id`, `last_ping`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `first_watch_sessions` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned DEFAULT NULL,
    +            `session_id` varchar(64) DEFAULT NULL,
    +            `is_first_time` tinyint(1) NOT NULL DEFAULT 1,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            `last_seen` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_ep_seen` (`episode_id`, `last_seen`),
    +            KEY `idx_user` (`user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_17_200001_create_social_features_tables', 'batch' => $batch]);
    +        echo "✓ Sosyal özellikler tabloları oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ Sosyal özellikler tabloları zaten mevcut\n\n";
    +}
    +
    +// Topluluk özellikleri tabloları (Mahkeme, Kapsül, Spoiler)
    +if (!$sch->hasTable('tribunals')) {
    +    echo "▶ Topluluk özellikleri tabloları oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `tribunals` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `anime_id` bigint unsigned NOT NULL,
    +            `episode_id` bigint unsigned DEFAULT NULL,
    +            `created_by` bigint unsigned NOT NULL,
    +            `question` varchar(280) NOT NULL,
    +            `side_a` varchar(100) NOT NULL,
    +            `side_b` varchar(100) NOT NULL,
    +            `extra_sides` json DEFAULT NULL,
    +            `status` enum('open','closed') NOT NULL DEFAULT 'open',
    +            `verdict` varchar(1) DEFAULT NULL,
    +            `closes_at` timestamp NULL DEFAULT NULL,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_anime` (`anime_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `tribunal_votes` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `tribunal_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `side` varchar(1) NOT NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_t_user` (`tribunal_id`,`user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `tribunal_arguments` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `tribunal_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `side` varchar(1) NOT NULL,
    +            `body` varchar(500) NOT NULL,
    +            `vote_count` smallint unsigned NOT NULL DEFAULT 0,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_t_arg_user` (`tribunal_id`,`user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `tribunal_argument_votes` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `argument_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_av_user` (`argument_id`,`user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `time_capsules` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `user_id` bigint unsigned NOT NULL,
    +            `anime_id` bigint unsigned NOT NULL,
    +            `message` text NOT NULL,
    +            `unlock_at` timestamp NOT NULL,
    +            `opened_at` timestamp NULL DEFAULT NULL,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_user_unlock` (`user_id`,`unlock_at`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `spoiler_boxes` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `body` text NOT NULL,
    +            `is_spoiler` tinyint(1) NOT NULL DEFAULT 0,
    +            `spoiler_score` tinyint NOT NULL DEFAULT 0,
    +            `likes` smallint unsigned NOT NULL DEFAULT 0,
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_ep` (`episode_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `spoiler_box_likes` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `box_id` bigint unsigned NOT NULL,
    +            `user_id` bigint unsigned NOT NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_box_user` (`box_id`,`user_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->statement("CREATE TABLE IF NOT EXISTS `mood_logs` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `user_id` bigint unsigned DEFAULT NULL,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `mood` varchar(20) NOT NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_17_300000_create_community_features_tables', 'batch' => $batch]);
    +        echo "✓ Topluluk özellikleri tabloları oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ Topluluk özellikleri tabloları zaten mevcut\n\n";
    +}
    +
    +// ── Bot koruma & gelişmiş analitik tabloları ─────────────────────────────────
    +if (!$sch->hasTable('analytics_bot_logs') || !$sch->hasTable('analytics_sessions') || !$sch->hasTable('blocked_ips')) {
    +    echo "▶ Bot koruma tabloları oluşturuluyor...\n";
    +    try {
    +        if (!$sch->hasTable('analytics_bot_logs')) {
    +            $db->statement("CREATE TABLE `analytics_bot_logs` (
    +                `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    +                `ip` VARCHAR(45) NOT NULL,
    +                `user_agent` VARCHAR(500) NULL,
    +                `path` VARCHAR(500) NULL,
    +                `method` VARCHAR(10) NULL,
    +                `action` VARCHAR(20) NOT NULL DEFAULT 'allowed',
    +                `bot_name` VARCHAR(100) NULL,
    +                `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    +                INDEX `idx_ip` (`ip`),
    +                INDEX `idx_action` (`action`),
    +                INDEX `idx_created` (`created_at`)
    +            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +            echo "  ✓ analytics_bot_logs\n";
    +        }
    +        if (!$sch->hasTable('blocked_ips')) {
    +            $db->statement("CREATE TABLE `blocked_ips` (
    +                `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    +                `ip` VARCHAR(45) NOT NULL,
    +                `reason` VARCHAR(255) NULL,
    +                `auto_blocked` TINYINT(1) DEFAULT 0,
    +                `blocked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    +                `expires_at` TIMESTAMP NULL,
    +                UNIQUE KEY `uk_ip` (`ip`)
    +            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +            echo "  ✓ blocked_ips\n";
    +        }
    +        if (!$sch->hasTable('analytics_sessions')) {
    +            $db->statement("CREATE TABLE `analytics_sessions` (
    +                `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    +                `session_id` VARCHAR(100) NOT NULL,
    +                `user_id` BIGINT UNSIGNED NULL,
    +                `ip` VARCHAR(45) NULL,
    +                `country` VARCHAR(100) NULL,
    +                `city` VARCHAR(100) NULL,
    +                `device` VARCHAR(20) NULL,
    +                `browser` VARCHAR(50) NULL,
    +                `referrer` VARCHAR(500) NULL,
    +                `landing_page` VARCHAR(500) NULL,
    +                `pages_visited` INT DEFAULT 1,
    +                `total_seconds` INT DEFAULT 0,
    +                `is_bot` TINYINT(1) DEFAULT 0,
    +                `bot_type` VARCHAR(50) NULL,
    +                `user_agent` VARCHAR(500) NULL,
    +                `started_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    +                `last_seen_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    +                INDEX `idx_session` (`session_id`),
    +                INDEX `idx_started` (`started_at`),
    +                INDEX `idx_bot` (`is_bot`)
    +            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +            echo "  ✓ analytics_sessions\n";
    +        }
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_18_000001_create_bot_protection_tables', 'batch' => $batch]);
    +        echo "✓ Bot koruma tabloları hazır\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ Bot koruma tabloları zaten mevcut\n\n";
    +}
    +
    +// analytics_pageviews: is_bot, user_agent, time_on_page kolonları ekle
    +if ($sch->hasTable('analytics_pageviews')) {
    +    $cols = ['is_bot' => 'TINYINT(1) DEFAULT 0', 'user_agent' => 'VARCHAR(500) NULL', 'time_on_page' => 'INT DEFAULT 0'];
    +    foreach ($cols as $col => $def) {
    +        if (!$sch->hasColumn('analytics_pageviews', $col)) {
    +            try {
    +                $db->statement("ALTER TABLE `analytics_pageviews` ADD COLUMN `{$col}` {$def}");
    +                echo "  ✓ analytics_pageviews.{$col} eklendi\n";
    +            } catch (\Throwable $e) {
    +                echo "  ✗ {$col}: " . $e->getMessage() . "\n";
    +            }
    +        }
    +    }
    +}
    +
    +// ── Moderatör izinleri & kullanıcı aktivite logları ──────────────────────────
    +if (!$sch->hasTable('moderator_permissions')) {
    +    echo "▶ moderator_permissions tablosu oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `moderator_permissions` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `user_id` bigint unsigned NOT NULL,
    +            `permission` varchar(80) NOT NULL,
    +            `granted_by` bigint unsigned NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_user_perm` (`user_id`, `permission`),
    +            KEY `idx_user_id` (`user_id`),
    +            CONSTRAINT `fk_modperm_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_19_000001_create_moderator_permissions_table', 'batch' => $batch]);
    +        echo "✓ moderator_permissions tablosu oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ moderator_permissions tablosu zaten mevcut\n\n";
    +}
    +
    +if (!$sch->hasTable('user_activity_logs')) {
    +    echo "▶ user_activity_logs tablosu oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `user_activity_logs` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `user_id` bigint unsigned NULL,
    +            `session_id` varchar(64) NULL,
    +            `action` varchar(60) NOT NULL,
    +            `subject_type` varchar(60) NULL,
    +            `subject_id` bigint unsigned NULL,
    +            `ip` varchar(45) NULL,
    +            `country` varchar(80) NULL,
    +            `city` varchar(80) NULL,
    +            `device` varchar(20) NULL,
    +            `browser` varchar(50) NULL,
    +            `user_agent` varchar(500) NULL,
    +            `is_bot` tinyint(1) NOT NULL DEFAULT 0,
    +            `meta` json NULL,
    +            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_user_created` (`user_id`, `created_at`),
    +            KEY `idx_action_created` (`action`, `created_at`),
    +            KEY `idx_country` (`country`),
    +            KEY `idx_is_bot` (`is_bot`),
    +            KEY `idx_session` (`session_id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_19_000001_create_moderator_permissions_table', 'batch' => $batch]);
    +        echo "✓ user_activity_logs tablosu oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ user_activity_logs tablosu zaten mevcut\n\n";
    +}
    +
    +// analytics_pageviews: is_bot, user_agent, time_on_page kolonları (önceki bloktan bağımsız tekrar kontrol)
    +if ($sch->hasTable('analytics_pageviews')) {
    +    foreach (['is_bot' => 'TINYINT(1) DEFAULT 0', 'user_agent' => 'VARCHAR(500) NULL', 'time_on_page' => 'SMALLINT UNSIGNED DEFAULT 0'] as $col => $def) {
    +        if (!$sch->hasColumn('analytics_pageviews', $col)) {
    +            try {
    +                $db->statement("ALTER TABLE `analytics_pageviews` ADD COLUMN `{$col}` {$def}");
    +                echo "  ✓ analytics_pageviews.{$col} eklendi\n";
    +            } catch (\Throwable $e) {
    +                echo "  ✗ {$col}: " . $e->getMessage() . "\n";
    +            }
    +        }
    +    }
    +    $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_19_000002_add_bot_columns_to_analytics_pageviews', 'batch' => $batch]);
    +}
    +echo "\n";
    +
    +// ── SEO tabloları ─────────────────────────────────────────────────────────
    +if (!$sch->hasTable('seo_keyword_tracker')) {
    +    echo "▶ seo_keyword_tracker tablosu oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `seo_keyword_tracker` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `keyword` varchar(255) NOT NULL,
    +            `target_url` varchar(500) NULL,
    +            `search_volume` int DEFAULT NULL,
    +            `difficulty` tinyint unsigned DEFAULT NULL,
    +            `notes` text NULL,
    +            `created_at` timestamp NULL,
    +            `updated_at` timestamp NULL,
    +            PRIMARY KEY (`id`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_19_000010_create_seo_keyword_tracker_table', 'batch' => $batch]);
    +        echo "✓ seo_keyword_tracker oluşturuldu\n\n";
    +    } catch (\Throwable $e) { echo "✗ HATA: " . $e->getMessage() . "\n\n"; }
    +} else { echo "✓ seo_keyword_tracker zaten mevcut\n\n"; }
    +
    +if (!$sch->hasTable('seo_redirects')) {
    +    echo "▶ seo_redirects tablosu oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `seo_redirects` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `from_path` varchar(500) NOT NULL,
    +            `to_path` varchar(500) NOT NULL,
    +            `type` smallint NOT NULL DEFAULT 301,
    +            `hits` int NOT NULL DEFAULT 0,
    +            `is_active` tinyint(1) NOT NULL DEFAULT 1,
    +            `created_at` timestamp NULL,
    +            `updated_at` timestamp NULL,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `seo_redirects_from_unique` (`from_path`(191))
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_19_000011_create_seo_redirects_table', 'batch' => $batch]);
    +        echo "✓ seo_redirects oluşturuldu\n\n";
    +    } catch (\Throwable $e) { echo "✗ HATA: " . $e->getMessage() . "\n\n"; }
    +} else { echo "✓ seo_redirects zaten mevcut\n\n"; }
    +
    +foreach (['seo_title' => 'VARCHAR(100) NULL', 'seo_meta_desc' => 'VARCHAR(320) NULL', 'seo_keywords' => 'VARCHAR(500) NULL'] as $col => $def) {
    +    if (!$sch->hasColumn('animes', $col)) {
    +        try {
    +            $db->statement("ALTER TABLE `animes` ADD COLUMN `{$col}` {$def}");
    +            echo "  ✓ animes.{$col} eklendi\n";
    +        } catch (\Throwable $e) { echo "  ✗ {$col}: " . $e->getMessage() . "\n"; }
    +    }
    +}
    +$db->table('migrations')->insertOrIgnore(['migration' => '2026_04_19_000012_add_seo_columns_to_animes', 'batch' => $batch]);
    +echo "\n";
    +
    +// extra_sides kolonu yoksa ekle (tribunals tablosu önceden oluşturulmuşsa)
    +if ($sch->hasTable('tribunals') && !$sch->hasColumn('tribunals', 'extra_sides')) {
    +    echo "▶ tribunals.extra_sides kolonu ekleniyor...\n";
    +    try {
    +        $db->statement("ALTER TABLE `tribunals` ADD COLUMN `extra_sides` json DEFAULT NULL AFTER `side_b`");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_04_17_310000_add_extra_sides_to_tribunals', 'batch' => $batch]);
    +        echo "✓ extra_sides eklendi\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ tribunals.extra_sides zaten mevcut\n\n";
    +}
    +
    +// ── Animecix scraper tabloları ────────────────────────────────────────────────
    +
    +// 1. import_jobs: source, animecix_title_id, animecix_slug kolonları
    +if (!$sch->hasColumn('import_jobs', 'animecix_title_id')) {
    +    echo "▶ import_jobs animecix kolonları ekleniyor...\n";
    +    try {
    +        if (!$sch->hasColumn('import_jobs', 'source')) {
    +            $db->statement("ALTER TABLE `import_jobs` ADD COLUMN `source` VARCHAR(30) NOT NULL DEFAULT 'anizium' AFTER `id`");
    +        }
    +        $db->statement("ALTER TABLE `import_jobs` ADD COLUMN `animecix_title_id` VARCHAR(50) NULL AFTER `source`");
    +        $db->statement("ALTER TABLE `import_jobs` ADD COLUMN `animecix_slug` VARCHAR(300) NULL AFTER `animecix_title_id`");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2024_01_01_000120_add_animecix_fields_to_import_jobs', 'batch' => $batch]);
    +        echo "✓ import_jobs animecix kolonları eklendi\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ import_jobs animecix kolonları zaten mevcut\n\n";
    +}
    +
    +// 2. video_sources tablosu
    +if (!$sch->hasTable('video_sources')) {
    +    echo "▶ video_sources tablosu oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `video_sources` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `episode_id` bigint unsigned NOT NULL,
    +            `label` varchar(120) NOT NULL DEFAULT '',
    +            `url` varchar(2000) NOT NULL,
    +            `type` enum('mp4','hls','embed') NOT NULL DEFAULT 'mp4',
    +            `quality` varchar(20) NOT NULL DEFAULT '',
    +            `translator_id` varchar(60) NULL,
    +            `sort_order` smallint unsigned NOT NULL DEFAULT 0,
    +            `is_default` tinyint(1) NOT NULL DEFAULT 0,
    +            `source` varchar(30) NOT NULL DEFAULT 'animecix',
    +            `created_at` timestamp NULL DEFAULT NULL,
    +            `updated_at` timestamp NULL DEFAULT NULL,
    +            PRIMARY KEY (`id`),
    +            KEY `idx_episode_sort` (`episode_id`, `sort_order`),
    +            KEY `idx_source` (`source`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2024_01_01_000121_create_video_sources_table', 'batch' => $batch]);
    +        echo "✓ video_sources tablosu oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ video_sources tablosu zaten mevcut\n\n";
    +}
    +
    +// 3. episodes.source ENUM'a 'animecix' ekle
    +try {
    +    $enumCheck = $db->select("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'episodes' AND COLUMN_NAME = 'source'");
    +    $enumDef = $enumCheck[0]->COLUMN_TYPE ?? '';
    +    if (!str_contains($enumDef, 'animecix')) {
    +        echo "▶ episodes.source ENUM'a animecix ekleniyor...\n";
    +        $db->statement("ALTER TABLE `episodes` MODIFY COLUMN `source` ENUM('bunnycdn','external','direct','anizium','animecix') NOT NULL DEFAULT 'bunnycdn'");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2024_01_01_000122_add_animecix_to_episodes_source_enum', 'batch' => $batch]);
    +        echo "✓ episodes.source güncellendi\n\n";
    +    } else {
    +        echo "✓ episodes.source zaten animecix içeriyor\n\n";
    +    }
    +} catch (\Throwable $e) {
    +    echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +}
    +
    +// 4. nextJob() için: import_jobs.source kolonu varsa INDEX ekle (performans)
    +try {
    +    $idxCheck = $db->select("SHOW INDEX FROM import_jobs WHERE Key_name = 'idx_source_status'");
    +    if (empty($idxCheck) && $sch->hasColumn('import_jobs', 'source')) {
    +        $db->statement("ALTER TABLE `import_jobs` ADD INDEX `idx_source_status` (`source`, `status`)");
    +        echo "✓ import_jobs(source, status) index eklendi\n\n";
    +    }
    +} catch (\Throwable $e) {}
    +
    +// ── 2026_05_14_000001: membership_plans.is_public, visible_until / users.admin_badge ──
    +echo "▶ Plan görünürlük & admin rozet kolonları kontrol ediliyor...\n";
    +$planVis = [
    +    'is_public'     => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `is_active`",
    +    'visible_until' => "TIMESTAMP NULL DEFAULT NULL AFTER `is_public`",
    +];
    +foreach ($planVis as $col => $def) {
    +    if (!$sch->hasColumn('membership_plans', $col)) {
    +        try {
    +            $db->statement("ALTER TABLE `membership_plans` ADD COLUMN `{$col}` {$def}");
    +            echo "  ✓ membership_plans.{$col} eklendi\n";
    +        } catch (\Throwable $e) { echo "  ✗ {$col}: " . $e->getMessage() . "\n"; }
    +    } else {
    +        echo "  ✓ membership_plans.{$col} zaten mevcut\n";
    +    }
    +}
    +if (!$sch->hasColumn('users', 'admin_badge')) {
    +    try {
    +        $db->statement("ALTER TABLE `users` ADD COLUMN `admin_badge` VARCHAR(32) NULL");
    +        echo "  ✓ users.admin_badge eklendi\n";
    +    } catch (\Throwable $e) { echo "  ✗ admin_badge: " . $e->getMessage() . "\n"; }
    +} else {
    +    echo "  ✓ users.admin_badge zaten mevcut\n";
    +}
    +$db->table('migrations')->insertOrIgnore(['migration' => '2026_05_14_000001_add_plan_visibility_and_admin_badge', 'batch' => $batch]);
    +echo "\n";
    +
    +// ── 2026_05_14_000002: membership_plans.trial_days / users.profile_music_url ──
    +echo "▶ Deneme süresi & profil müzik kolonları kontrol ediliyor...\n";
    +if (!$sch->hasColumn('membership_plans', 'trial_days')) {
    +    try {
    +        $db->statement("ALTER TABLE `membership_plans` ADD COLUMN `trial_days` SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER `duration_days`");
    +        echo "  ✓ membership_plans.trial_days eklendi\n";
    +    } catch (\Throwable $e) { echo "  ✗ trial_days: " . $e->getMessage() . "\n"; }
    +} else {
    +    echo "  ✓ membership_plans.trial_days zaten mevcut\n";
    +}
    +if (!$sch->hasColumn('users', 'profile_music_url')) {
    +    try {
    +        $db->statement("ALTER TABLE `users` ADD COLUMN `profile_music_url` VARCHAR(500) NULL");
    +        echo "  ✓ users.profile_music_url eklendi\n";
    +    } catch (\Throwable $e) { echo "  ✗ profile_music_url: " . $e->getMessage() . "\n"; }
    +} else {
    +    echo "  ✓ users.profile_music_url zaten mevcut\n";
    +}
    +$db->table('migrations')->insertOrIgnore(['migration' => '2026_05_14_000002_add_trial_days_and_profile_music', 'batch' => $batch]);
    +echo "\n";
    +
    +// ── payments tablosu ──────────────────────────────────────────────────────────
    +if (!$sch->hasTable('payments')) {
    +    echo "▶ payments tablosu oluşturuluyor...\n";
    +    try {
    +        $db->statement("CREATE TABLE IF NOT EXISTS `payments` (
    +            `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    +            `user_id` bigint unsigned NOT NULL,
    +            `plan_id` bigint unsigned NOT NULL,
    +            `conversation_id` varchar(100) NOT NULL,
    +            `token` varchar(200) NULL,
    +            `amount` decimal(10,2) NOT NULL,
    +            `status` enum('pending','success','failed') NOT NULL DEFAULT 'pending',
    +            `iyzico_payment_id` varchar(100) NULL,
    +            `error_message` text NULL,
    +            `paid_at` timestamp NULL,
    +            `created_at` timestamp NULL,
    +            `updated_at` timestamp NULL,
    +            PRIMARY KEY (`id`),
    +            UNIQUE KEY `uniq_conversation` (`conversation_id`),
    +            KEY `idx_user` (`user_id`),
    +            KEY `idx_token` (`token`(191)),
    +            KEY `idx_status` (`status`)
    +        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    +        $db->table('migrations')->insertOrIgnore(['migration' => '2026_05_14_000003_create_payments_table', 'batch' => $batch]);
    +        echo "✓ payments tablosu oluşturuldu\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +} else {
    +    echo "✓ payments tablosu zaten mevcut\n\n";
    +}
    +
    +$commands = [
    +    ['migrate',  ['--force' => true]],
    +    ['db:seed',  ['--class' => 'AchievementSeeder', '--force' => true]],
    +    ['config:clear', []],
    +    ['view:clear',   []],
    +    ['route:clear',  []],
    +];
    +
    +foreach ($commands as [$cmd, $args]) {
    +    echo "▶ php artisan $cmd " . implode(' ', array_keys($args)) . "\n";
    +    try {
    +        Illuminate\Support\Facades\Artisan::call($cmd, $args);
    +        echo Illuminate\Support\Facades\Artisan::output();
    +        echo "✓ Tamam\n\n";
    +    } catch (\Throwable $e) {
    +        echo "✗ HATA: " . $e->getMessage() . "\n\n";
    +    }
    +}
    +
    +echo "=== Tamamlandı ===\n";
    +echo '
    '; diff --git a/public/opensearch.xml b/public/opensearch.xml new file mode 100644 index 0000000..fb06f45 --- /dev/null +++ b/public/opensearch.xml @@ -0,0 +1,13 @@ + + + Animexe + Animexe'de Türkçe anime ara ve izle + UTF-8 + /favicon.ico + + + /search + tr-TR + anime izle türkçe altyazı + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..18e449f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,58 @@ +# Animexe — robots.txt +# Otomatik yönetim: Admin > SEO Paneli üzerinden düzenlenebilir. + +User-agent: * +Allow: / +Disallow: /admin/ +Disallow: /api/ +Disallow: /profile +Disallow: /profile/settings +Disallow: /watchlist +Disallow: /notifications +Disallow: /ai +Disallow: /watch/ +Disallow: /vtt-proxy +Disallow: /track/ +Disallow: /comments +Crawl-delay: 1 + +User-agent: Googlebot +Allow: / +Disallow: /admin/ +Disallow: /api/ + +User-agent: Bingbot +Allow: / +Disallow: /admin/ +Disallow: /api/ + +# AI eğitim botlarını engelle (standart User-agent yöntemi) +User-agent: GPTBot +Disallow: / + +User-agent: ChatGPT-User +Disallow: / + +User-agent: CCBot +Disallow: / + +User-agent: anthropic-ai +Disallow: / + +User-agent: Claude-Web +Disallow: / + +User-agent: cohere-ai +Disallow: / + +User-agent: Omgili +Disallow: / + +User-agent: FacebookBot +Disallow: / + +Sitemap: https://animexe.com/sitemap.xml +Sitemap: https://animexe.com/sitemap-main.xml +Sitemap: https://animexe.com/sitemap-animes.xml +Sitemap: https://animexe.com/sitemap-videos.xml +Sitemap: https://animexe.com/sitemap-blog.xml diff --git a/public/setup.php b/public/setup.php new file mode 100644 index 0000000..bb69a37 --- /dev/null +++ b/public/setup.php @@ -0,0 +1,30 @@ +make(Illuminate\Contracts\Console\Kernel::class); + +echo '
    ';
    +
    +$kernel->call('migrate', ['--force' => true]);
    +echo "MIGRATE:\n" . $kernel->output() . "\n";
    +
    +$kernel->call('storage:link');
    +echo "STORAGE LINK:\n" . $kernel->output() . "\n";
    +
    +$kernel->call('config:cache');
    +echo "CONFIG CACHE:\n" . $kernel->output() . "\n";
    +
    +$kernel->call('route:cache');
    +echo "ROUTE CACHE:\n" . $kernel->output() . "\n";
    +
    +$kernel->call('view:cache');
    +echo "VIEW CACHE:\n" . $kernel->output() . "\n";
    +
    +echo '
    '; +echo '

    BU DOSYAYI HEMEN SİL! /public/setup.php

    '; diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/admin/activation-codes/index.blade.php b/resources/views/admin/activation-codes/index.blade.php new file mode 100644 index 0000000..580ee9f --- /dev/null +++ b/resources/views/admin/activation-codes/index.blade.php @@ -0,0 +1,356 @@ +@extends('admin.layouts.app') +@section('title', 'Aktivasyon Kodları') +@section('page-title', 'Aktivasyon Kodları') + +@section('content') + +{{-- İstatistik kartları --}} +
    +
    +
    +
    +
    {{ number_format($stats['total']) }}
    +
    Toplam Kod
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['used']) }}
    +
    Kullanılmış
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['unused']) }}
    +
    Kullanılmamış
    +
    +
    +
    +
    + +{{-- Kod Oluşturma Formu --}} +
    +
    +
    + Yeni Aktivasyon Kodu Oluştur +
    +
    + @csrf +
    +
    + + +
    +
    + + +
    +
    + + +
    Boş = süresiz
    +
    +
    + + +
    Grubu tanımlamak için
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    + +{{-- Oluşturulan kodlar (anlık göster) --}} +@if(session('generated_codes')) +
    +
    +
    +
    + {{ count(session('generated_codes')) }} Kod Oluşturuldu +
    +
    + +
    +
    +
    + @foreach(session('generated_codes') as $gc) + {{ $gc }} + @endforeach +
    +
    +
    +@endif + +@if(session('success')) +
    + {{ session('success') }} +
    +@endif + +{{-- Filtreler --}} +
    +
    +
    + + +
    +
    + + +
    + @if($batches->isNotEmpty()) +
    + + +
    + @endif +
    + + Temizle + + CSV + +
    +
    +
    + +{{-- Batch toplu silme --}} +@if(request('batch')) +
    +
    + @csrf + + +
    +
    +@endif + +{{-- Toplu seçim aksiyonu --}} + + +{{-- Gizli bulk delete formu --}} + + +{{-- Tablo --}} +
    +
    + + + + + + + + + + + + + + + + @forelse($codes as $code) + + + + + + + + + + + + @empty + + + + @endforelse + +
    + + KodPlanBatchDurumKullananSon KullanmaOluşturulma
    + @if(!$code->isUsed()) + + @endif + + {{ $code->code }} + + + {{ $code->plan->name ?? '—' }} + + {{ $code->batch ?? '—' }} + @if($code->isUsed()) + + Kullanıldı + + @elseif($code->isExpired()) + + Süresi Doldu + + @else + + Bekliyor + + @endif + + @if($code->usedBy) + {{ $code->usedBy->name }} +
    {{ $code->used_at?->format('d.m.Y H:i') }}
    + @else + + @endif +
    + {{ $code->expires_at?->format('d.m.Y') ?? '∞ Süresiz' }} + + {{ $code->created_at->format('d.m.Y') }} + + @if(!$code->isUsed()) +
    + @csrf @method('DELETE') + +
    + @endif +
    + + Henüz aktivasyon kodu yok. Yukarıdan oluşturabilirsin. +
    +
    +
    + +{{-- Pagination --}} +@if($codes->hasPages()) +
    {{ $codes->links() }}
    +@endif + + +@endsection diff --git a/resources/views/admin/ads/edit.blade.php b/resources/views/admin/ads/edit.blade.php new file mode 100644 index 0000000..b2cbd97 --- /dev/null +++ b/resources/views/admin/ads/edit.blade.php @@ -0,0 +1,117 @@ +@extends('admin.layouts.app') +@section('title', 'Reklam Düzenle') +@section('page-title', 'Reklam Düzenle') + +@section('content') + + +
    +
    + +
    + @csrf @method('PUT') +
    +
    + +
    {{ $ad->name }}
    +
    +
    + @if($errors->any()) +
    + @foreach($errors->all() as $err)
    {{ $err }}
    @endforeach +
    + @endif + + {{-- Önizleme --}} + @if($ad->media_url) +
    + @if($ad->type === 'video') + + @else + + @endif +
    + @endif + +
    +
    Gösterim: {{ number_format($ad->impressions) }}
    +
    Tıklama: {{ number_format($ad->clicks) }}
    +
    CTR: %{{ $ad->ctr }}
    +
    +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    0 = geçilemez
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + is_active ? 'checked' : '' }}> + +
    + +
    +
    +
    + +
    +
    +@endsection diff --git a/resources/views/admin/ads/index.blade.php b/resources/views/admin/ads/index.blade.php new file mode 100644 index 0000000..664b10d --- /dev/null +++ b/resources/views/admin/ads/index.blade.php @@ -0,0 +1,291 @@ +@extends('admin.layouts.app') +@section('title', 'Reklamlar') +@section('page-title', 'Reklam Yönetimi') + +@section('content') + +@if(session('success')) +
    + {{ session('success') }} +
    +@endif +@if($errors->any()) +
    +
    Reklam eklenemedi:
    + @foreach($errors->all() as $err)
    • {{ $err }}
    @endforeach +
    +@endif + +{{-- Özet kartlar --}} +
    +
    +
    +
    Toplam Gösterim
    +
    {{ number_format($stats['total_impressions']) }}
    +
    +
    +
    +
    +
    Toplam Tıklama
    +
    {{ number_format($stats['total_clicks']) }}
    +
    +
    +
    +
    +
    Ortalama CTR
    +
    %{{ $stats['avg_ctr'] }}
    +
    +
    +
    +
    +
    Aktif Reklam
    +
    {{ $stats['active_count'] }}
    +
    +
    +
    + +
    + {{-- Sol: Ayarlar --}} +
    +
    + @csrf +
    +
    + +
    Video Reklam Ayarları
    +
    +
    +
    + + +
    +
    + +
    + + bölüm +
    +
    2 = her 2 bölümde 1 reklam
    +
    +
    + +
    + + dakika +
    +
    5 = 5 dakika içinde en fazla 1 reklam
    +
    +
    + +
    + + % +
    +
    Reklam yerine "Premium'a geç" ekranı gösterme olasılığı. 20 = her 5 reklam slotundan 1'i premium teklifi.
    +
    +
    +
    + + +
    +
    Premium üyeler hiçbir reklamı görmez.
    + +
    +
    +
    + + {{-- Yeni Reklam --}} +
    + @csrf +
    +
    + +
    Yeni Reklam Ekle
    +
    +
    + @if($errors->any()) +
    + @foreach($errors->all() as $err)
    {{ $err }}
    @endforeach +
    + @endif +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    Dosya yüklemek yerine hazır bir URL de verebilirsin.
    +
    +
    + + +
    Reklama tıklanınca açılacak sayfa (boş bırakılabilir).
    +
    +
    +
    + + +
    0 = geçilemez
    +
    +
    + + +
    Yüksek = daha sık
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    +
    +
    + + {{-- Sağ: Reklam listesi --}} +
    +
    +
    + +
    Reklamlar ({{ $ads->count() }})
    +
    +
    + @if($ads->isEmpty()) +
    + +
    Henüz reklam eklenmedi.
    +
    + @else +
    + + + + + + + + + + + + + + @foreach($ads as $ad) + + + + + + + + + + @endforeach + +
    ReklamTür / KonumGösterimTıklamaCTRDurumİşlem
    +
    {{ $ad->name }}
    +
    + Ağırlık: {{ $ad->weight }} + @if($ad->type === 'video') · Geç: {{ $ad->skip_after > 0 ? $ad->skip_after.'sn' : 'kapalı' }} @endif + @if($ad->starts_at || $ad->ends_at) + · 📅 {{ $ad->starts_at?->format('d.m') ?? '∞' }}—{{ $ad->ends_at?->format('d.m') ?? '∞' }} + @endif +
    +
    + {{ $ad->type === 'video' ? 'Video' : 'Banner' }} +
    + {{ ['preroll' => 'Video başı', 'home_mid' => 'Ana sayfa orta', 'home_bottom' => 'Ana sayfa alt'][$ad->placement] ?? $ad->placement }} +
    +
    {{ number_format($ad->impressions) }}{{ number_format($ad->clicks) }}%{{ $ad->ctr }} +
    + @csrf + +
    +
    + + + +
    + @csrf @method('DELETE') + +
    +
    +
    + @endif +
    +
    +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/ai/anime-meta.blade.php b/resources/views/admin/ai/anime-meta.blade.php new file mode 100644 index 0000000..630e5c0 --- /dev/null +++ b/resources/views/admin/ai/anime-meta.blade.php @@ -0,0 +1,207 @@ +@extends('admin.layouts.app') +@section('title', 'AI — Toplu Anime Meta Doldurma') +@section('page-title', 'AI — Toplu Anime Meta Doldurma') + +@section('content') +
    +
    + + {{-- İstatistikler --}} +
    +
    +
    +
    {{ $total }}
    +
    Toplam anime
    +
    +
    +
    +
    +
    {{ $missing }}
    +
    Eksik meta alanı olan
    +
    +
    +
    +
    +
    {{ $noGenres }}
    +
    Kategori ataması yok
    +
    +
    +
    + +
    +
    Toplu Anime Meta Doldurma
    +

    + DeepSeek AI, eksik alanı olan animeleri tek tek işler. Açıklama, yıl, stüdyo, tür, durum, puan ve kategorileri doldurur. + Dolu alanların üstüne yazmaz. Anime başına ~3-5 sn sürer. +

    + +
    +
    + + +
    +
    + +
    + + + +
    + + {{-- İlerleme --}} + + + {{-- Log --}} + +
    + +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/ai/descriptions.blade.php b/resources/views/admin/ai/descriptions.blade.php new file mode 100644 index 0000000..6bfda09 --- /dev/null +++ b/resources/views/admin/ai/descriptions.blade.php @@ -0,0 +1,141 @@ +@extends('admin.layouts.app') +@section('title', 'AI Açıklama Yazma') +@section('page-title', 'AI — Toplu Bölüm Açıklaması') + +@section('content') + +
    +
    + +
    +
    + +
    Toplu Açıklama Yaz
    +
    +
    +
    + DeepSeek, seçilen animenin açıklaması boş olan tüm bölümlerine sırayla Türkçe açıklama yazar. + Bölüm başına ~2-3 saniye sürer. +
    + +
    + + +
    + +
    + + +
    +
    +
    + + {{-- Progress --}} + + + {{-- Result --}} + + +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/analytics/index.blade.php b/resources/views/admin/analytics/index.blade.php new file mode 100644 index 0000000..3b076d9 --- /dev/null +++ b/resources/views/admin/analytics/index.blade.php @@ -0,0 +1,802 @@ +@extends('admin.layouts.app') +@section('title', 'Analitik') +@section('page-title', 'Kullanıcı Analitikleri') + +@push('styles') + +@endpush + +@section('content') +
    + +{{-- Period seçici --}} +
    + Dönem: + @foreach(['today'=>'Bugün','7d'=>'7 Gün','30d'=>'30 Gün','90d'=>'90 Gün'] as $key=>$lbl) + {{ $lbl }} + @endforeach + + Canlı — Son güncelleme: {{ now()->format('H:i') }} + +
    + +{{-- Özet kartlar --}} +
    +
    +
    +
    +
    +
    {{ number_format($totalViews) }}
    +
    Sayfa Görüntüleme
    + @if($period === '7d' || $period === 'today') +
    + + {{ abs($viewsDelta) }}% dünden +
    + @endif +
    + +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($uniqueVisitors) }}
    +
    Tekil Ziyaretçi
    +
    + +
    +
    +
    +
    +
    +
    +
    + @php + $wh = floor($watchSeconds / 3600); + $wm = floor(($watchSeconds % 3600) / 60); + @endphp +
    {{ $wh > 0 ? $wh.'s' : $wm.'d' }}
    +
    Toplam İzleme Süresi
    +
    {{ number_format($watchSeconds/3600, 1) }} saat
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($aiTotal) }}
    +
    AI Sorgusu
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($newUsers) }}
    +
    Yeni Kayıt
    +
    + +
    +
    +
    +
    + +{{-- Görüntüleme trendi + Cihaz dağılımı --}} +
    +
    +
    +
    +
    Görüntüleme ve İzleme Trendi
    +
    + Görüntüleme + İzleme (saat) +
    +
    +
    + +
    +
    +
    +
    +
    +
    Cihaz Dağılımı
    +
    + +
    +
    + @php $deviceTotal = $deviceStats->sum(); @endphp + @foreach(['mobile'=>['bi-phone','var(--accent)'],'desktop'=>['bi-laptop','var(--success)'],'tablet'=>['bi-tablet','#79c0ff']] as $dv=>[$ico,$col]) + @if(($cnt=$deviceStats[$dv]??0) > 0) +
    + + {{ ucfirst($dv) }} +
    + {{ number_format($cnt) }} +
    + @endif + @endforeach +
    +
    +
    +
    + +{{-- Top anime + AI sorgular --}} +
    +
    +
    +
    En Çok İzlenen 10 Anime
    + @if($topAnimes->count()) + @php $maxV = $topAnimes->max('views') ?: 1; @endphp + @foreach($topAnimes as $i => $a) +
    + {{ $i+1 }} + {{ $a['title'] }} +
    + {{ number_format($a['views']) }} +
    + @endforeach + @else +
    Bu dönemde veri yok
    + @endif +
    +
    +
    +
    +
    AI Sorgu Türleri
    +
    + +
    + @php + $aiLabels = ['chat'=>'Sohbet','recommend'=>'Öneri','search'=>'Arama','episode_info'=>'Bölüm Analiz','similar'=>'Benzer']; + $aiColors = ['chat'=>'var(--accent)','recommend'=>'#a371f7','search'=>'var(--success)','episode_info'=>'#f0883e','similar'=>'#79c0ff']; + $aiTotalQ = $aiByType->sum(); + @endphp +
    + @foreach($aiByType->sortDesc() as $type => $cnt) +
    + + {{ $aiLabels[$type]??$type }} + {{ number_format($cnt) }} + {{ $aiTotalQ>0?round($cnt/$aiTotalQ*100):0 }}% +
    + @endforeach + @if($aiByType->isEmpty()) +
    Bu dönemde AI sorgusu yok
    + @endif +
    +
    +
    +
    + +{{-- Saatlik dağılım --}} +
    +
    +
    +
    Bugünkü Saatlik Görüntüleme Dağılımı
    +
    + +
    +
    +
    +
    + +{{-- Top bölümler + Coğrafi --}} +
    +
    +
    +
    En Çok İzlenen Bölümler (İzleme Süresi)
    + @if($topEpisodes->count()) + + + + + + @foreach($topEpisodes as $ep) + + + + + + + + @endforeach + +
    AnimeBölümOynatmaSüreOrt. %
    {{ $ep['anime'] }}{{ $ep['label'] }}{{ number_format($ep['plays']) }}{{ $ep['hours'] }} s + @php $pct=$ep['avg_pct']??0; @endphp + %{{ $pct }} +
    + @else +
    Bu dönemde izleme verisi yok
    + @endif +
    +
    +
    +
    +
    Coğrafi Dağılım (Şehir)
    + @if($geoStats->count()) + @php $maxGeo = $geoStats->max('cnt') ?: 1; @endphp + @foreach($geoStats as $g) +
    + + {{ $g->city }} + · {{ $g->country }} + +
    + {{ $g->cnt }} +
    + @endforeach + @else +
    Coğrafi veri yok (IP geolocation bekliyor)
    + @endif +
    +
    +
    + +{{-- En aktif kullanıcılar + AI top sorular --}} +
    +
    +
    +
    En Aktif Kullanıcılar
    + @if($activeUsers->count()) + + + + + + @foreach($activeUsers as $i => $au) + + + + + + + @endforeach + +
    #KullanıcıGörüntülemeAktif Gün
    {{ $i+1 }} +
    {{ $au['user']->name }}
    +
    {{ $au['user']->email }}
    +
    {{ number_format($au['views']) }}{{ $au['days'] }} gün
    + @else +
    Bu dönemde giriş yapan kullanıcı yok
    + @endif +
    +
    +
    +
    +
    Popüler AI Soruları (Sohbet)
    + @if($aiTopQuestions->count()) + @foreach($aiTopQuestions as $q) +
    + +
    +
    {{ $q->query_text }}
    +
    {{ $q->cnt }} kez soruldu
    +
    +
    + @endforeach + @else +
    Bu dönemde chat sorusu yok
    + @endif + + @if($aiTopUsers->count()) +
    AI'ı En Çok Kullananlar
    + @foreach($aiTopUsers as $au) +
    + {{ $au['name'] }} + {{ $au['cnt'] }} sorgu +
    + @endforeach + @endif +
    +
    +
    + +{{-- Sayfa türleri + Tarayıcılar --}} +
    +
    +
    +
    Sayfa Türü Dağılımı
    + @php + $ptLabels = ['home'=>'Ana Sayfa','anime'=>'Anime Detay','player'=>'Video Player','search'=>'Arama','ai'=>'AI Hub','genre'=>'Tür','other'=>'Diğer']; + $ptColors = ['home'=>'#79c0ff','anime'=>'var(--success)','player'=>'#a371f7','search'=>'#f0883e','ai'=>'var(--accent)','genre'=>'#ffa657','other'=>'#6e7681']; + $ptTotal = $pageTypeStats->sum(); + @endphp +
    + @foreach($pageTypeStats->sortDesc() as $pt => $cnt) +
    +
    + + {{ $ptLabels[$pt]??$pt }} + {{ number_format($cnt) }} +
    +
    +
    + @endforeach +
    +
    +
    +
    +
    +
    Tarayıcı Dağılımı
    + @php $brTotal = $browserStats->sum(); @endphp + @foreach($browserStats->sortDesc()->take(6) as $br => $cnt) +
    + {{ $br ?: 'Diğer' }} +
    + {{ number_format($cnt) }} + {{ $brTotal>0?round($cnt/$brTotal*100):0 }}% +
    + @endforeach +
    +
    +
    + +{{-- Trafik Kaynakları --}} +
    +
    +
    +
    Trafik Kaynakları (Referrer)
    + @php + $refTotal = $referrerStats->sum() + $directTraffic; + $allSources = collect(['Direkt / Boş' => $directTraffic])->merge($referrerStats)->sortDesc(); + $srcColors = ['Direkt / Boş'=>'var(--text2)']; + $palette = ['var(--accent)','var(--success)','#79c0ff','#f0883e','#a371f7','#ffa657','#58a6ff','#7ee787','#ff7bc6','#d2a8ff','#ffa198','#56d364']; + $pi = 0; + foreach($referrerStats->keys() as $k) { $srcColors[$k] = $palette[$pi % count($palette)]; $pi++; } + @endphp + @if($allSources->sum() > 0) + @foreach($allSources->take(12) as $src => $cnt) +
    + + + @if($src === 'Direkt / Boş') + {{ $src }} + @else + {{ $src }} + @endif + +
    + {{ number_format($cnt) }} + {{ $refTotal>0?round($cnt/$refTotal*100):0 }}% +
    + @endforeach + @else +
    Bu dönemde referrer verisi yok
    + @endif +
    +
    +
    +
    +
    Kaynak Dağılımı
    +
    + +
    +
    +
    + Toplam kayıtlı trafik: {{ number_format($refTotal) }} +
    +
    + Direkt: {{ $refTotal>0?round($directTraffic/$refTotal*100):0 }}% · Referral: {{ $refTotal>0?round($referrerStats->sum()/$refTotal*100):0 }}% +
    +
    +
    +
    +
    + +{{-- Son aktiviteler (canlı akış) --}} +
    +
    +
    Son Sayfa Görüntülemeleri
    + +
    + + + + + + @forelse($recentViews as $rv) + + + + + + + + + @empty + + @endforelse + +
    ZamanKullanıcıSayfa TürüURLŞehirCihaz
    {{ $rv->created_at->diffForHumans() }} + @if($rv->user) + {{ $rv->user->name }} + @else + Misafir + @endif + {{ $rv->page_type }}{{ $rv->url }}{{ $rv->city ?: '—' }}
    Bu dönemde görüntüleme yok
    +
    + +{{-- ── BOT TRAFİK PANELİ ─────────────────────────────────────────────────── --}} +
    +
    + 🤖 Bot & Güvenlik + Son dönem +
    + + {{-- Bot özet kartları --}} +
    +
    +
    {{ number_format($botViews) }}
    +
    Bot İsteği
    +
    +
    +
    {{ number_format($humanViews) }}
    +
    İnsan İsteği
    +
    +
    +
    {{ $botRatio ?? 0 }}%
    +
    Bot Oranı
    +
    +
    +
    {{ $blockedIps->count() }}
    +
    Engelli IP
    +
    +
    + +
    + + {{-- En çok vuran botlar --}} +
    +
    Top Bot IP'leri
    + + + + @forelse($botTopIps as $b) + + + + + + + @empty + + @endforelse + +
    IPİstekDurum
    {{ $b->ip }}{{ number_format($b->cnt) }} + + {{ $b->action }} + + +
    + @csrf + + + +
    +
    Bot logu yok
    +
    + + {{-- Bot türleri --}} +
    +
    Bot Türleri
    + + + + @forelse($botByName as $b) + + + + + + @empty + + @endforelse + +
    BotİstekDurum
    {{ $b->bot_name ?: '—' }}{{ number_format($b->cnt) }} + + {{ $b->action }} + +
    Kayıt yok
    +
    +
    + + {{-- Manuel IP Engelleme --}} +
    +
    Manuel IP Engelle
    +
    + @csrf +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + + {{-- Engelli IP listesi --}} + @if($blockedIps->count()) +
    +
    Aktif Engeller
    + + + + @foreach($blockedIps as $bi) + + + + + + + + @endforeach + +
    IPSebepTürBitiş
    {{ $bi->ip }}{{ $bi->reason ?: '—' }}{{ $bi->auto_blocked ? 'Otomatik' : 'Manuel' }}{{ $bi->expires_at ? \Carbon\Carbon::parse($bi->expires_at)->diffForHumans() : 'Kalıcı' }} +
    + @csrf + + +
    +
    +
    + @endif +
    + +{{-- ── OTURUM DETAYLARI ────────────────────────────────────────────────────── --}} +
    +
    + 📊 Oturum Analizi +
    + Ort. Süre: {{ gmdate('i:s', $avgSessionTime) }} + Ort. Sayfa: {{ $avgPages }} +
    +
    + + + + + + + + + @forelse($sessions as $s) + + + + + + + + + + + + @empty + + @endforelse + +
    IPÜlkeCihazTarayıcıSayfalarSüreKaynakBot?Başlangıç
    {{ $s->ip }}{{ $s->country ?: '—' }}{{ $s->browser ?: '—' }}{{ $s->pages_visited }}{{ gmdate('i:s', $s->total_seconds) }}{{ $s->referrer ? parse_url($s->referrer, PHP_URL_HOST) : 'Direkt' }} + @if($s->is_bot) + {{ $s->bot_type }} + @else + İnsan + @endif + {{ \Carbon\Carbon::parse($s->started_at)->diffForHumans() }}
    Oturum kaydı yok
    +
    + +
    +@endsection + +@push('scripts') + + +@endpush + diff --git a/resources/views/admin/analytics/user-detail.blade.php b/resources/views/admin/analytics/user-detail.blade.php new file mode 100644 index 0000000..d811c00 --- /dev/null +++ b/resources/views/admin/analytics/user-detail.blade.php @@ -0,0 +1,126 @@ +@extends('admin.layouts.app') +@section('title', 'Kullanıcı Aktivitesi — ' . $user->name) + +@section('content') +
    + +
    + @if($user->avatar) + + @else +
    {{ strtoupper(substr($user->name,0,1)) }}
    + @endif +
    +

    {{ $user->name }} {{ $user->role }}

    + {{ $user->email }} · Üyelik: {{ $user->created_at->format('d.m.Y') }} +
    +
    +
    +
    + + +
    + + Profili Gör + +
    +
    + +
    + {{-- Aksiyon Özeti --}} +
    +
    +
    Aksiyon Özeti
    +
    + @forelse($actBreakdown as $ab) + @php $labels = \App\Models\UserActivityLog::$actionLabels; @endphp +
    + {{ $labels[$ab->action] ?? $ab->action }} + {{ $ab->cnt }} +
    + @empty +

    Bu dönemde aktivite yok

    + @endforelse +
    +
    + + {{-- Son izleme --}} +
    +
    Son İzlemeler
    +
    + @forelse($watchEvents as $w) +
    +
    {{ $w->anime_title }}
    +
    + Bölüm {{ $w->episode_number }} + %{{ $w->percent_complete }} +
    +
    +
    +
    + {{ \Carbon\Carbon::parse($w->created_at)->diffForHumans() }} +
    + @empty +
    İzleme verisi yok
    + @endforelse +
    +
    +
    + + {{-- Aktivite Log Tablosu --}} +
    +
    +
    Detaylı Aktivite Günlüğü
    +
    + + + + + + @forelse($logs as $log) + @php $labels = \App\Models\UserActivityLog::$actionLabels; @endphp + + + + + + + + + @empty + + @endforelse + +
    AksiyonKonuIPÜlkeCihazZaman
    {{ $labels[$log->action] ?? $log->action }}{{ $log->subject_type ? class_basename($log->subject_type).'#'.$log->subject_id : '—' }}{{ $log->ip }}{{ $log->country ?: '—' }}{{ $log->device ?: '—' }}{{ \Carbon\Carbon::parse($log->created_at)->format('d.m.Y H:i') }}
    Aktivite bulunamadı
    +
    + +
    + + {{-- Son Sayfa Görüntülemeleri --}} +
    +
    Son Sayfa Görüntülemeleri
    +
    + + + + @forelse($pageviews as $pv) + + + + + + + @empty + + @endforelse + +
    URLSayfa TipiSüreZaman
    {{ $pv->url }}{{ $pv->page_type }}{{ isset($pv->time_on_page) && $pv->time_on_page > 0 ? $pv->time_on_page.'s' : '—' }}{{ \Carbon\Carbon::parse($pv->created_at)->format('d.m H:i') }}
    Sayfa görüntüleme yok
    +
    +
    +
    +
    +@endsection diff --git a/resources/views/admin/analytics/users.blade.php b/resources/views/admin/analytics/users.blade.php new file mode 100644 index 0000000..ae83bbf --- /dev/null +++ b/resources/views/admin/analytics/users.blade.php @@ -0,0 +1,375 @@ +@extends('admin.layouts.app') +@section('title', 'Kullanıcı Analitiği') + +@section('content') +
    +
    +

    Kullanıcı Analitiği

    +

    Gerçek kullanıcılar, bot trafiği, ülke dağılımı ve aktivite takibi

    +
    +
    + + + @if($country) + {{ $country }} + @endif +
    +
    + +{{-- Overview Cards --}} +
    +
    +
    +
    {{ number_format($totalReal) }}
    + Toplam Kullanıcı +
    +
    +
    +
    +
    +{{ number_format($newReal) }}
    + Yeni Üye +
    +
    +
    +
    +
    {{ number_format($active30) }}
    + Aktif Kullanıcı +
    +
    +
    +
    +
    {{ number_format($realViews) }}
    + Gerçek Görüntüleme +
    +
    +
    +
    +
    {{ number_format($botViews) }}
    + Bot Görüntüleme +
    +
    +
    +
    + @php $ratio = ($realViews+$botViews) > 0 ? round($botViews/($realViews+$botViews)*100,1) : 0; @endphp +
    {{ $ratio }}%
    + Bot Oranı +
    +
    +
    + +{{-- Tabs --}} + + +{{-- ── Overview Tab ──────────────────────────────────────────────────────────── --}} +@if($tab === 'overview') +
    + {{-- Daily new users chart --}} +
    +
    +
    Günlük Yeni Üye
    +
    +
    +
    + + {{-- Device breakdown --}} +
    +
    +
    Cihaz Dağılımı (gerçek kullanıcı)
    +
    + @foreach($deviceBreakdown as $dev) + @php $pct = $realViews > 0 ? round($dev->cnt/$realViews*100,1) : 0; @endphp +
    + {{ $dev->device ?: 'bilinmiyor' }} +
    +
    +
    +
    + {{ number_format($dev->cnt) }} ({{ $pct }}%) +
    +
    + @endforeach +
    +
    +
    + + {{-- Top active users --}} +
    +
    +
    En Aktif Kullanıcılar
    +
    + + + + @foreach($topUsers as $i => $tu) + @php $u = $topUserMap[$tu->user_id] ?? null; @endphp + + + + + + @endforeach + +
    #KullanıcıAksiyon
    {{ $i+1 }} + @if($u) + {{ $u->name }} +
    {{ $u->email }} + @else #{{ $tu->user_id }} @endif +
    {{ number_format($tu->actions) }}
    +
    +
    +
    + + {{-- Action breakdown --}} +
    +
    +
    Aksiyon Dağılımı
    +
    + @foreach($actionBreakdown as $ab) + @php $labels = \App\Models\UserActivityLog::$actionLabels; @endphp +
    + {{ $labels[$ab->action] ?? $ab->action }} + {{ number_format($ab->cnt) }} +
    + @endforeach + @if($actionBreakdown->isEmpty())

    Henüz aktivite yok

    @endif +
    +
    +
    +
    + +{{-- ── Activity Tab ──────────────────────────────────────────────────────────── --}} +@elseif($tab === 'activity') +
    +
    + Aktivite Günlüğü +
    + + + + + + +
    +
    +
    + + + + + + @forelse($actLogs as $log) + @php $labels = \App\Models\UserActivityLog::$actionLabels; @endphp + + + + + + + + + + @empty + + @endforelse + +
    KullanıcıAksiyonKonuIPÜlkeCihazZaman
    + @if($log->user) + {{ $log->user->name }} + @else Misafir @endif + {{ $labels[$log->action] ?? $log->action }}{{ $log->subject_type ? class_basename($log->subject_type).'#'.$log->subject_id : '—' }}{{ $log->ip }} + @if($log->country) + {{ $log->country }} + @else — @endif + {{ $log->device }}{{ \Carbon\Carbon::parse($log->created_at)->diffForHumans() }}
    Kayıt bulunamadı
    +
    + +
    + +{{-- ── Bots Tab ──────────────────────────────────────────────────────────────── --}} +@elseif($tab === 'bots') +
    +
    +
    +
    Bot Trafik Analizi
    +
    + + + + @forelse($botStats as $b) + + + + + + @empty + + @endforelse + +
    Bot / AraçAksiyonİstek
    {{ $b->bot_name }} + + {{ $b->action }} + + {{ number_format($b->cnt) }}
    Bot logu bulunamadı
    +
    +
    +
    + +
    +
    +
    En Fazla İstek Atan IP'ler (Botlar)
    +
    + + + + @forelse($topBotIps as $bip) + @php $blocked = $blockedIps->firstWhere('ip', $bip->ip); @endphp + + + + + + + @empty + + @endforelse + +
    IPİstekDurum
    {{ $bip->ip }}{{ number_format($bip->cnt) }} + @if($blocked) + Engelli + @else + Serbest + @endif + + @if(!$blocked) +
    + @csrf + + +
    + @else +
    + @csrf + +
    + @endif +
    Bot IP kaydı yok
    +
    +
    +
    + +
    +
    +
    Engelli IP'ler ({{ $blockedIps->count() }})
    +
    + + + + @forelse($blockedIps as $bi) + + + + + + + + + @empty + + @endforelse + +
    IPSebepOtomatikEngellenmeBitiş
    {{ $bi->ip }}{{ $bi->reason }}{{ $bi->auto_blocked ? 'Otomatik' : 'Manuel' }}{{ \Carbon\Carbon::parse($bi->blocked_at)->format('d.m.Y H:i') }}{{ $bi->expires_at ? \Carbon\Carbon::parse($bi->expires_at)->diffForHumans() : 'Kalıcı' }} +
    + @csrf + +
    +
    Engelli IP yok
    +
    +
    +
    +
    + +{{-- ── Country Tab ───────────────────────────────────────────────────────────── --}} +@elseif($tab === 'country') +
    +
    Ülke Bazlı Trafik (Gerçek Kullanıcı)
    +
    + + + + @php $totalViews = $countries->sum('views'); @endphp + @forelse($countries as $i => $c) + @php $pct = $totalViews > 0 ? round($c->views/$totalViews*100,1) : 0; @endphp + + + + + + + + + @empty + + @endforelse + +
    #ÜlkeGörüntülemeBenzersiz KullanıcıOran
    {{ $i+1 }}{{ $c->country ?: 'Bilinmiyor' }}{{ number_format($c->views) }}{{ number_format($c->users) }} +
    +
    +
    +
    + {{ $pct }}% +
    +
    + Aktivite +
    Ülke verisi bulunamadı
    +
    +
    +@endif + +@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/admin/anime-requests/index.blade.php b/resources/views/admin/anime-requests/index.blade.php new file mode 100644 index 0000000..5bb5393 --- /dev/null +++ b/resources/views/admin/anime-requests/index.blade.php @@ -0,0 +1,117 @@ +@extends('admin.layouts.app') +@section('title','Anime İstekleri') +@section('page-title','Anime İstekleri') + +@section('content') + + + +
    +
    + + + + + + + + + + + + + @forelse($requests as $req) + @php $s = \App\Models\AnimeRequest::STATUSES[$req->status] ?? ['label'=>$req->status,'color'=>'#8b87a8']; @endphp + + + + + + + + + @empty + + + + @endforelse + +
    AnimeİsteyenOyDurumTarihİşlem
    +
    {{ $req->title }}
    + @if($req->original_title) +
    {{ $req->original_title }}
    + @endif + @if($req->note) +
    + {{ Str::limit($req->note,70) }} +
    + @endif + @if($req->admin_note) +
    + {{ $req->admin_note }} +
    + @endif +
    + @if($req->user) +
    {{ $req->user->name }}
    +
    {{ $req->user->email }}
    + @else + Silinmiş + @endif +
    + + {{ $req->vote_count }} + + + + {{ $s['label'] }} + + {{ $req->created_at->format('d.m.Y') }} +
    + @foreach(['approved'=>['Onayla','success'],'added'=>['Eklendi','info'],'rejected'=>['Reddet','danger']] as $st=>[$lbl,$clr]) + @if($req->status !== $st) +
    + @csrf @method('PATCH') + + +
    + @endif + @endforeach +
    + @csrf @method('DELETE') + +
    +
    +
    +
    + +
    Bu kategoride istek yok
    +
    +
    +
    +
    + +
    + {{ $requests->withQueryString()->links('admin.partials.pagination') }} +
    + +@endsection diff --git a/resources/views/admin/animes/create.blade.php b/resources/views/admin/animes/create.blade.php new file mode 100644 index 0000000..e2b4af1 --- /dev/null +++ b/resources/views/admin/animes/create.blade.php @@ -0,0 +1,334 @@ +@extends('admin.layouts.app') +@section('title', 'Anime Ekle') +@section('page-title', 'Anime Ekle') + +@push('styles') + +@endpush + +@section('content') + +{{-- Breadcrumb --}} + + +
    +@csrf + +
    + + {{-- ── Sol: Form Alanları ── --}} +
    + + {{-- Temel Bilgiler --}} +
    +
    + +
    Temel Bilgiler
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + {{-- Detaylar --}} +
    +
    + +
    Detaylar
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    + + {{-- Türler --}} +
    +
    + +
    Türler
    +
    +
    +
    + @foreach($genres as $genre) + + @endforeach +
    +
    +
    + + {{-- İzin Ayarları --}} + @if(isset($permissions) && count($permissions)) +
    +
    + +
    İzin Ayarları
    + — Bu anime için özel izinler +
    +
    + @foreach($permissions as $perm) +
    +
    +
    {{ $perm->label }}
    + @if($perm->description) +
    {{ $perm->description }}
    + @endif +
    +
    + + + + +
    +
    + @endforeach +
    +
    + @endif + +
    + + {{-- ── Sağ: Görsel & Ayarlar ── --}} +
    + + {{-- Kapak Görseli --}} +
    +
    + +
    Kapak Görseli
    +
    +
    + +
    + +
    + + Kapak yükle + Önerilen: 300×450px +
    +
    +
    +
    + + {{-- Banner Görseli --}} +
    +
    + +
    Banner Görseli
    +
    +
    + +
    + + +
    +
    +
    + + {{-- Yayın Ayarları --}} +
    +
    + +
    Yayın Ayarları
    +
    +
    +
    +
    +
    Yayınla
    +
    Kullanıcılara görünür yap
    +
    +
    + +
    +
    +
    +
    +
    Öne Çıkar
    +
    Ana sayfada öne çıkar
    +
    +
    + +
    +
    +
    +
    +
    Türkçe Dublaj
    +
    Ana sayfada Dublaj bölümünde göster
    +
    +
    + +
    +
    +
    +
    + + {{-- Kaydet --}} + + + İptal + +
    + +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/animes/edit.blade.php b/resources/views/admin/animes/edit.blade.php new file mode 100644 index 0000000..a9037b5 --- /dev/null +++ b/resources/views/admin/animes/edit.blade.php @@ -0,0 +1,369 @@ +@extends('admin.layouts.app') +@section('title', 'Düzenle: ' . $anime->title) +@section('page-title', $anime->title) + +@push('styles') + +@endpush + +@section('content') + +
    + + Geri Dön + +
    + Son güncelleme: {{ $anime->updated_at->format('d.m.Y H:i') }} +
    +
    + +{{-- AI Banner --}} +
    +
    +
    + AI ile Tüm Boşları Doldur +
    +
    + Açıklama, yıl, stüdyo, tür, durum ve puan bilgilerini DeepSeek'ten çeker. Yalnızca boş alanları doldurur. +
    +
    + +
    + + +
    +@csrf @method('PUT') + +
    +
    + + {{-- Temel Bilgiler --}} +
    +
    + +
    Temel Bilgiler
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    + + +
    +
    +
    + + {{-- Detaylar --}} +
    +
    + +
    Detaylar
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + @if(!$anime->mal_id) + ⚠ MAL ID girilmemiş — İntro Atla çalışmaz + @endif +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    + + {{-- Türler --}} +
    +
    + +
    Türler
    +
    +
    +
    + @foreach($genres as $genre) + + @endforeach +
    +
    +
    + + {{-- İzinler --}} + @if(isset($permissions) && count($permissions)) +
    +
    + +
    İzin Ayarları
    + — Global ayarları override eder +
    +
    + @foreach($permissions as $perm) + @php $current = $contentPerms[$perm->key] ?? 'free'; @endphp +
    +
    +
    {{ $perm->label }}
    + @if($perm->description)
    {{ $perm->description }}
    @endif +
    +
    + + + + +
    +
    + @endforeach +
    +
    + @endif + +
    + + {{-- ── Sağ Kolon ── --}} +
    + + {{-- Kapak --}} +
    +
    + +
    Kapak Görseli
    +
    +
    + @if($anime->coverUrl) + + @endif + + +
    Önerilen: 300×450px
    +
    +
    + + {{-- Banner --}} +
    +
    + +
    Banner Görseli
    +
    +
    + +
    Önerilen: 1280×720px
    +
    +
    + + {{-- Yayın --}} +
    +
    + +
    Yayın Ayarları
    +
    +
    +
    +
    +
    Yayınla
    +
    Kullanıcılara görünür
    +
    +
    + is_published ? 'checked' : '' }}> +
    +
    +
    +
    +
    Öne Çıkar
    +
    Ana sayfada öne çıkar
    +
    +
    + is_featured ? 'checked' : '' }}> +
    +
    +
    +
    +
    Türkçe Dublaj
    +
    Ana sayfada Dublaj bölümünde göster
    +
    +
    + is_dubbed ? 'checked' : '' }}> +
    +
    +
    +
    + + + İptal +
    +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/animes/index.blade.php b/resources/views/admin/animes/index.blade.php new file mode 100644 index 0000000..eb667db --- /dev/null +++ b/resources/views/admin/animes/index.blade.php @@ -0,0 +1,488 @@ +@extends('admin.layouts.app') +@section('title', 'Animeler') +@section('page-title', 'Animeler') + +@push('styles') + +@endpush + +@section('content') + +{{-- ── Page Header ── --}} + + +{{-- ── MAL Progress Card ── --}} +
    +
    +
    + MAL ID Otomatik Doldurma +
    + +
    +
    +
    +
    +
    +
    + +{{-- ── Filters ── --}} +
    +
    +
    + + +
    + + + +
    + + +
    +
    +
    + +{{-- ── Bulk Selection Bar ── --}} +
    + 0 anime seçildi + Tüm sayfalardaki animeler +
    + + + + +
    +
    + +{{-- ── Table ── --}} +
    +
    + + + + + + + + + + + + + + @forelse($animes as $anime) + + + + + + + + + + @empty + + + + @endforelse + +
    + + AnimeTip / DurumMAL IDBölümYayınİşlem
    + + +
    + @if($anime->coverUrl) + + @else +
    + @endif +
    +
    {{ $anime->title }}
    +
    {{ $anime->genres->pluck('name')->join(', ') ?: '—' }}
    +
    +
    +
    +
    + {{ strtoupper($anime->type) }} + @if($anime->status === 'ongoing') + Devam Ediyor + @elseif($anime->status === 'completed') + Tamamlandı + @else + Yakında + @endif +
    +
    + @if($anime->mal_id) + + {{ $anime->mal_id }} + + @else + + Eksik + + + @endif + + + {{ $anime->episode_count }} + + + @if($anime->is_published) + + Yayında + + @else + + Taslak + + @endif + +
    + +
    + + Görüntüle + + + Düzenle + + + Bölümler + +
    +
    + @csrf @method('DELETE') + +
    +
    +
    +
    +
    + +
    Anime bulunamadı
    +

    Arama kriterlerinizi değiştirin veya yeni anime ekleyin.

    + + Anime Ekle + +
    +
    +
    +
    + +{{-- ── Pagination ── --}} +
    + {{ $animes->links('admin.partials.pagination') }} +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/animes/show.blade.php b/resources/views/admin/animes/show.blade.php new file mode 100644 index 0000000..b52e673 --- /dev/null +++ b/resources/views/admin/animes/show.blade.php @@ -0,0 +1,247 @@ +@extends('admin.layouts.app') +@section('title', $anime->title) +@section('page-title', $anime->title) + +@section('content') + +
    + + Animelere Dön + +
    + + + Düzenle + +
    +
    + +
    + + {{-- ── Sol: Sezonlar & Bölümler ── --}} +
    + @forelse($anime->seasons as $season) +
    +
    +
    + {{ $season->season_number }}. Sezon + @if($season->title) + — {{ $season->title }} + @endif + @if($season->mal_id) + MAL: {{ $season->mal_id }} + @endif +
    +
    + + Bölüm Ekle + +
    + @csrf @method('DELETE') + +
    +
    +
    + @if($season->episodes->count() > 0) +
    + + + + + + + + + + + + @foreach($season->episodes as $ep) + + + + + + + + @endforeach + +
    BölümBaşlıkDurumİzlenme
    + + {{ str_pad($ep->episode_number,2,'0',STR_PAD_LEFT) }} + + {{ $ep->title ?: '—' }} + @if($ep->is_published) + Yayında + @else + Taslak + @endif + {{ number_format($ep->view_count) }} + + + +
    +
    + @else +
    + +

    Henüz bölüm yok.

    + Bölüm Ekle +
    + @endif +
    + @empty +
    +
    +
    + +
    Henüz sezon eklenmemiş
    + +
    +
    +
    + @endforelse + + {{-- İzin Ayarları --}} + @if(isset($permissions) && count($permissions)) +
    +
    + +
    Bu Anime İçin İzin Ayarları
    + — Global ayarları override eder +
    +
    +
    + @csrf + @foreach($permissions as $perm) + @php $current = $contentPerms[$perm->key] ?? 'free'; @endphp +
    +
    +
    {{ $perm->label }}
    + @if($perm->description)
    {{ $perm->description }}
    @endif +
    +
    + + + + +
    +
    + @endforeach +
    + +
    +
    +
    +
    + @endif +
    + + {{-- ── Sağ: Anime Bilgileri ── --}} +
    +
    +
    + @if($anime->coverUrl) + {{ $anime->title }} + @endif +
    {{ $anime->title }}
    + @if($anime->title_en) +
    {{ $anime->title_en }}
    + @endif +
    + {{ strtoupper($anime->type) }} + + {{ $anime->is_published?'Yayında':'Taslak' }} + + @if($anime->is_featured) + Öne Çıkan + @endif +
    +
    +
    + +
    +
    + +
    Detaylar
    +
    +
    + @foreach([ + ['Durum', $anime->status, ''], + ['Yayın Yılı', $anime->release_year ?: '—', ''], + ['Stüdyo', $anime->studio ?: '—', ''], + ['Puan', $anime->rating ?: '—', ''], + ['MAL ID', $anime->mal_id ?: '—', ''], + ['Türler', $anime->genres->pluck('name')->join(', ') ?: '—', ''], + ] as [$lbl, $val]) +
    + {{ $lbl }} + {{ $val }} +
    + @endforeach +
    +
    + + +
    +
    + +{{-- Sezon Ekle Modal --}} + + +@endsection diff --git a/resources/views/admin/auth/login.blade.php b/resources/views/admin/auth/login.blade.php new file mode 100644 index 0000000..eb0c299 --- /dev/null +++ b/resources/views/admin/auth/login.blade.php @@ -0,0 +1,291 @@ + + + + + +Giriş — Animexe Admin + + + + + + + + +
    + + + + + + diff --git a/resources/views/admin/banners/index.blade.php b/resources/views/admin/banners/index.blade.php new file mode 100644 index 0000000..266f944 --- /dev/null +++ b/resources/views/admin/banners/index.blade.php @@ -0,0 +1,123 @@ +@extends('admin.layouts.app') +@section('title', 'Bannerlar') +@section('page-title', 'Bannerlar') + +@section('content') + + + +
    + + {{-- Ekle --}} +
    +
    +
    + +
    Yeni Banner Ekle
    +
    +
    +
    + @csrf +
    + + +
    +
    + + +
    Önerilen: 1920×900px
    +
    +
    + + +
    +
    + + +
    Küçük sayı önce gösterilir
    +
    +
    +
    +
    Aktif
    +
    Sitede göster
    +
    +
    + +
    +
    + +
    +
    +
    +
    + + {{-- Liste --}} +
    +
    +
    + +
    Mevcut Bannerlar
    + {{ $banners->count() }} adet +
    +
    + @forelse($banners as $banner) +
    + {{-- Görsel --}} +
    + @if($banner->imageUrl) + {{ $banner->title }} + @else +
    + +
    + @endif +
    + + {{-- Bilgi --}} +
    +
    {{ $banner->title }}
    + @if($banner->link) +
    {{ $banner->link }}
    + @endif +
    Sıra: {{ $banner->sort_order }}
    +
    + + {{-- Durum + Sil --}} +
    + @if($banner->is_active) + Aktif + @else + Pasif + @endif +
    + @csrf @method('DELETE') + +
    +
    +
    + @empty +
    + +
    Henüz banner eklenmemiş
    +

    Sol panelden ilk bannerınızı ekleyebilirsiniz.

    +
    + @endforelse +
    +
    +
    + +
    +@endsection diff --git a/resources/views/admin/blog/edit.blade.php b/resources/views/admin/blog/edit.blade.php new file mode 100644 index 0000000..455d064 --- /dev/null +++ b/resources/views/admin/blog/edit.blade.php @@ -0,0 +1,248 @@ +@extends('admin.layouts.app') +@section('title', isset($blog->id) ? 'Blog Düzenle' : 'Yeni Blog Yazısı') +@section('page-title', isset($blog->id) ? 'Blog Yazısı Düzenle' : 'Yeni Blog Yazısı') + +@push('styles') + +@endpush + +@section('content') +
    + {{-- LEFT: AI Üretici --}} +
    +
    +
    + + AI Blog Yazısı Üretici + — DeepSeek ile otomatik SEO içeriği üret +
    +
    +
    + + +
    + +
    +
    +
    +
    + + {{-- FORM --}} +
    +
    + @csrf + @if(isset($blog->id)) @method('PUT') @endif + +
    +
    + {{-- Başlık --}} +
    + + +
    {{ mb_strlen(old('title', $blog->title ?? '')) }}/60
    +
    + + {{-- Excerpt --}} +
    + + +
    {{ mb_strlen(old('excerpt', $blog->excerpt ?? '')) }}/160
    +
    + + {{-- Content --}} +
    + + +
    HTML etiketleri desteklenir. AI içeriği doğrudan buraya eklenir.
    +
    +
    + +
    + {{-- SEO Önizleme --}} +
    + +
    +
    animexe.com/blog/...
    +
    {{ old('title', $blog->title ?? 'Başlık giriniz') }}
    +
    {{ old('meta_description', $blog->meta_description ?? 'Açıklama giriniz') }}
    +
    +
    + + {{-- Yayın Durumu --}} +
    + + +
    + + {{-- Anime --}} +
    + + +
    + + {{-- Kapak Görseli --}} +
    + + +
    + + {{-- Odak Kelime --}} +
    + + +
    + + {{-- Meta Description --}} +
    + + +
    {{ mb_strlen(old('meta_description', $blog->meta_description ?? '')) }}/160
    +
    + + {{-- Meta Keywords --}} +
    + + +
    + + {{-- Okuma Süresi --}} +
    + + +
    + + + İptal +
    +
    +
    +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/blog/index.blade.php b/resources/views/admin/blog/index.blade.php new file mode 100644 index 0000000..14ab086 --- /dev/null +++ b/resources/views/admin/blog/index.blade.php @@ -0,0 +1,165 @@ +@extends('admin.layouts.app') +@section('title', 'Blog Yönetimi') +@section('page-title', 'Blog Yazıları') + +@section('content') + + + +{{-- Stats --}} +
    +
    +
    +
    +
    {{ $stats['total'] }}
    Toplam
    +
    +
    +
    +
    +
    +
    +
    +
    {{ $stats['published'] }}
    Yayında
    +
    +
    +
    +
    +
    +
    +
    +
    {{ $stats['draft'] }}
    Taslak
    +
    +
    +
    +
    +
    +
    +
    +
    {{ $stats['ai'] }}
    AI Üretimi
    +
    +
    +
    +
    +
    + +{{-- Tablo --}} +
    +
    + + + + + + + + + + + + + + @forelse($posts as $post) + + + + + + + + + + @empty + + + + @endforelse + +
    BaşlıkAnimeDurumKaynakGörüntülenmeTarihİşlem
    +
    {{ $post->title }}
    + @if($post->focus_keyword) +
    {{ $post->focus_keyword }}
    + @endif +
    + @if($post->anime) + {{ Str::limit($post->anime->title,25) }} + @else + + @endif + + @if($post->status === 'published') + Yayında + @elseif($post->status === 'generating') + Üretiliyor + @else + Taslak + @endif + + @if($post->ai_generated) + + AI + + @else + + Manuel + + @endif + {{ number_format($post->views) }}{{ $post->created_at->format('d.m.Y') }} +
    + @if($post->status === 'published') + + + + @endif + + + +
    + @csrf @method('DELETE') + +
    +
    +
    +
    + +
    Henüz blog yazısı yok
    +

    Yeni yazı ekleyin veya AI ile otomatik üretin.

    + + Yeni Yazı + +
    +
    +
    +
    + +@if($posts->hasPages()) +
    {{ $posts->appends(['q'=>$q])->links('admin.partials.pagination') }}
    +@endif + +@endsection diff --git a/resources/views/admin/comments/index.blade.php b/resources/views/admin/comments/index.blade.php new file mode 100644 index 0000000..788cf78 --- /dev/null +++ b/resources/views/admin/comments/index.blade.php @@ -0,0 +1,249 @@ +@extends('admin.layouts.app') +@section('title', 'Yorumlar') +@section('page-title', 'Yorumlar') + +@push('styles') + +@endpush + +@section('content') + + + +{{-- Filter --}} +
    +
    + @if(request('status')) + + @endif +
    + + +
    +
    + + +
    +
    +
    + +{{-- Table --}} +
    +
    + + + + + + + + + + + + + @forelse($comments as $comment) + @php + $commentable = $comment->commentable; + $isEpisode = $commentable instanceof \App\Models\Episode; + $isAnime = $commentable instanceof \App\Models\Anime; + @endphp + + {{-- Kullanıcı --}} + + + {{-- Nereye --}} + + + {{-- İçerik --}} + + + {{-- Durum --}} + + + {{-- Tarih --}} + + + {{-- İşlemler --}} + + + @empty + + + + @endforelse + +
    KullanıcıNereyeİçerikDurumTarihİşlemler
    +
    +
    {{ strtoupper(substr($comment->user?->name ?? '?', 0, 1)) }}
    + {{ $comment->user?->name ?? 'Silinmiş' }} +
    +
    + @if($comment->parent_id) + ↩ Yanıt + @endif + @if($comment->is_pinned) + Sabit + @endif +
    +
    + @if($isEpisode && $commentable->anime) +
    {{ Str::limit($commentable->anime->title, 20) }}
    + + S{{ str_pad($commentable->season->season_number ?? 1,2,'0',STR_PAD_LEFT) }}E{{ str_pad($commentable->episode_number,2,'0',STR_PAD_LEFT) }} + + + @elseif($isAnime) + + {{ Str::limit($commentable->title, 22) }} + + @else + {{ class_basename($comment->commentable_type) }} + @endif +
    + @if($comment->content) +
    {{ Str::limit($comment->content, 100) }}
    + @endif + @if($comment->gif_url) + gif + @endif + + {{-- Yanıt formu --}} +
    +
    + @csrf + + +
    +
    +
    + @php + $sb = match($comment->status) { + 'approved' => ['bg-success','Onaylı'], + 'pending' => ['bg-warning','Bekliyor'], + 'spam' => ['bg-secondary','Spam'], + default => ['bg-danger','Reddedilmiş'], + }; + @endphp + {{ $sb[1] }} + + {{ $comment->created_at->format('d.m.Y') }}
    + {{ $comment->created_at->format('H:i') }} +
    +
    + @if($comment->status !== 'approved') +
    + @csrf + +
    + @endif + @if($comment->status !== 'rejected') +
    + @csrf + +
    + @endif +
    + @csrf + +
    + +
    + @csrf @method('DELETE') + +
    +
    +
    +
    + +
    Yorum bulunamadı
    +

    Seçili filtreler için yorum bulunmuyor.

    +
    +
    +
    +
    + +
    + {{ $comments->links('admin.partials.pagination') }} +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 0000000..5100fcf --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,192 @@ +@extends('admin.layouts.app') +@section('title', 'Dashboard') +@section('page-title', 'Dashboard') + +@section('content') + +{{-- ── Stat Cards ── --}} +
    +
    +
    +
    +
    +
    {{ number_format($stats['total_users']) }}
    +
    Toplam Kullanıcı
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['premium_users']) }}
    +
    Premium Üye
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['total_animes']) }}
    +
    Toplam Anime
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['total_episodes']) }}
    +
    Toplam Bölüm
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['total_comments']) }}
    +
    Toplam Yorum
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['pending_comments']) }}
    +
    Bekleyen Yorum
    +
    +
    +
    + @if($stats['pending_comments'] > 0) + + İncele + + @endif +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['active_subs']) }}
    +
    Aktif Abonelik
    +
    +
    +
    +
    +
    +
    + +{{-- ── Alt Paneller ── --}} +
    + + {{-- Son Kullanıcılar --}} +
    +
    +
    + +
    Son Kayıtlar
    + Tümü +
    +
    + @forelse($recent_users as $user) +
    +
    {{ strtoupper(substr($user->name, 0, 1)) }}
    +
    +
    {{ $user->name }}
    +
    {{ $user->created_at->diffForHumans() }}
    +
    + @if($user->membership === 'premium') + PRO + @else + Ücretsiz + @endif +
    + @empty +
    + +

    Henüz kullanıcı yok.

    +
    + @endforelse +
    +
    +
    + + {{-- Son Animeler --}} +
    +
    +
    + +
    Son Animeler
    + Tümü +
    +
    + @forelse($recent_animes as $anime) +
    + @if($anime->cover_image) + + @else +
    + @endif +
    +
    {{ $anime->title }}
    +
    {{ $anime->created_at->diffForHumans() }}
    +
    + + {{ $anime->is_published ? 'Yayında' : 'Taslak' }} + +
    + @empty +
    + +

    Henüz anime eklenmemiş.

    +
    + @endforelse +
    +
    +
    + + {{-- Son Yorumlar --}} +
    +
    +
    + +
    Son Yorumlar
    + Tümü +
    +
    + @forelse($recent_comments as $comment) +
    +
    + {{ $comment->user?->name ?? 'Silinmiş Kullanıcı' }} + + {{ match($comment->status) { 'approved'=>'Onaylı', 'pending'=>'Bekliyor', default=>'Reddedildi' } }} + +
    +
    {{ Str::limit($comment->content, 65) }}
    +
    + @empty +
    + +

    Henüz yorum yok.

    +
    + @endforelse +
    +
    +
    +
    + +@endsection diff --git a/resources/views/admin/episodes/create.blade.php b/resources/views/admin/episodes/create.blade.php new file mode 100644 index 0000000..2b6a84c --- /dev/null +++ b/resources/views/admin/episodes/create.blade.php @@ -0,0 +1,230 @@ +@extends('admin.layouts.app') +@section('title', 'Bölüm Ekle') +@section('page-title', 'Bölüm Ekle') + +@push('styles') + +@endpush + +@section('content') + + + +
    +@csrf + +
    +
    + + {{-- Bölüm Bilgileri --}} +
    +
    + +
    Bölüm Bilgileri
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + {{-- Video Kaynağı --}} +
    +
    + +
    Video Kaynağı
    +
    +
    +
    + +
    + @foreach(['bunnycdn'=>'BunnyCDN','external'=>'External URL','direct'=>'Direct M3U8'] as $val => $lbl) + + @endforeach +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + {{-- İzinler --}} + @if(isset($permissions) && count($permissions)) +
    +
    + +
    İzin Ayarları
    + — Boş bırakınca anime/global ayarları geçerli olur +
    +
    + @foreach($permissions as $perm) +
    +
    +
    {{ $perm->label }}
    +
    +
    + + + + +
    +
    + @endforeach +
    +
    + @endif + +
    + + {{-- Sağ --}} +
    + + {{-- Thumbnail --}} +
    +
    + +
    Thumbnail
    +
    +
    + +
    Önerilen: 1280×720px
    +
    +
    + + {{-- Yayın --}} +
    +
    + +
    Yayın Ayarı
    +
    +
    +
    +
    +
    Yayınla
    +
    Kullanıcılara görünür yap
    +
    +
    + +
    +
    +
    +
    + + + İptal +
    +
    +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/episodes/edit.blade.php b/resources/views/admin/episodes/edit.blade.php new file mode 100644 index 0000000..c9e2789 --- /dev/null +++ b/resources/views/admin/episodes/edit.blade.php @@ -0,0 +1,371 @@ +@extends('admin.layouts.app') +@section('title', 'Bölüm Düzenle') +@section('page-title', 'Bölüm Düzenle') + +@push('styles') + +@endpush + +@section('content') + +
    + + Bölümlere Dön + +
    + Son güncelleme: {{ $episode->updated_at->format('d.m.Y H:i') }} +
    +
    + +
    +@csrf @method('PUT') + +
    +
    + + {{-- Bölüm Bilgileri --}} +
    +
    + +
    Bölüm Bilgileri
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + + +
    +
    +
    +
    + + {{-- İntro Ayarları --}} +
    +
    + +
    İntro / Outro Zaman Ayarı
    + — "İntroyu Atla" butonu için +
    +
    +
    +
    + + +
    +
    + + +
    +
    + @if($episode->intro_start && $episode->intro_end) +
    + + Mevcut: {{ $episode->intro_start }}sn — {{ $episode->intro_end }}sn + ({{ $episode->intro_end - $episode->intro_start }}sn süre) +
    + @endif +
    +
    + + {{-- Video Kaynağı --}} +
    +
    + +
    Video Kaynağı
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + {{-- İzinler --}} + @if(isset($permissions) && count($permissions)) +
    +
    + +
    İzin Ayarları
    +
    +
    + @foreach($permissions as $perm) + @php $current = $contentPerms[$perm->key] ?? 'free'; @endphp +
    +
    {{ $perm->label }}
    +
    + + + + +
    +
    + @endforeach +
    +
    + @endif + +
    + + {{-- Sağ --}} +
    + + {{-- İstatistikler --}} +
    +
    + +
    Bölüm İstatistikleri
    +
    +
    +
    + İzlenme + {{ number_format($episode->view_count) }} +
    +
    + Süre + {{ $episode->duration_formatted ?? ($episode->duration ? floor($episode->duration/60).'dk' : '—') }} +
    +
    + Kaynak + {{ strtoupper($episode->source) }} +
    +
    + Eklenme + {{ $episode->created_at->format('d.m.Y H:i') }} +
    +
    +
    + + {{-- Thumbnail --}} +
    +
    + +
    Thumbnail
    +
    +
    + @if($episode->thumbnailUrl) + + @endif + +
    Önerilen: 1280×720px
    +
    +
    + + {{-- Yayın --}} +
    +
    + +
    Yayın Ayarı
    +
    +
    +
    +
    +
    Yayınla
    +
    Kullanıcılara görünür yap
    +
    +
    + is_published?'checked':'' }}> +
    +
    +
    +
    + + + İptal +
    + +
    +
    + +{{-- Video Kaynakları + HEVC Tarama --}} +@php + $videoSources = \App\Models\VideoSource::where('episode_id', $episode->id)->orderBy('sort_order')->get(); +@endphp +@if($videoSources->count()) +
    +
    +
    + +
    Video Kaynakları ({{ $videoSources->count() }})
    +
    + +
    +
    +
    + @foreach($videoSources as $src) +
    +
    + {{ strtoupper($src->type) }} + {{ $src->label ?: '—' }} + @if($src->quality) + {{ $src->quality }} + @endif +
    +
    + @if($src->is_hevc) + H.265 + @elseif($src->hevc_checked_at) + H.264 ✓ + @else + Taranmadı + @endif + + + +
    +
    + @endforeach +
    + +
    +
    +@endif + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/episodes/index.blade.php b/resources/views/admin/episodes/index.blade.php new file mode 100644 index 0000000..4425910 --- /dev/null +++ b/resources/views/admin/episodes/index.blade.php @@ -0,0 +1,319 @@ +@extends('admin.layouts.app') +@section('title', 'Bölümler') +@section('page-title', 'Bölümler') + +@push('styles') + +@endpush + +@section('content') + +{{-- ── Page Header ── --}} + + +{{-- ── Toplu İntro Card ── --}} +
    +
    +
    + Toplu İntro Zaman Ayarı +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    + +{{-- ── Filters ── --}} +
    +
    +
    + +
    +
    + + +
    + +
    + + +
    +
    +
    + +{{-- ── Bulk Bar ── --}} +
    + 0 bölüm seçildi + Tüm sayfalardaki bölümler +
    + + + + +
    +
    + +{{-- ── Table ── --}} +
    +
    + + + + + + + + + + + + + @forelse($episodes as $ep) + + + + + + + + + @empty + + + + @endforelse + +
    + + Anime & BölümKaynakDurumİzlenmeİşlem
    + + +
    {{ $ep->anime->title ?? '—' }}
    +
    + S{{ str_pad($ep->season->season_number ?? 0, 2, '0', STR_PAD_LEFT) }}E{{ str_pad($ep->episode_number, 2, '0', STR_PAD_LEFT) }} + @if($ep->title) + {{ Str::limit($ep->title, 45) }} + @endif +
    +
    + {{ strtoupper($ep->source) }} + + @php + $sBadge = match($ep->status) { + 'published' => ['bg-success', 'Yayında', ''], + 'processing' => ['bg-info', 'İşleniyor', 'color:#000'], + 'failed' => ['bg-danger', 'Hatalı', ''], + default => ['bg-secondary','Bekliyor', ''], + }; + @endphp + {{ $sBadge[1] }} + + {{ number_format($ep->view_count) }} + +
    + + + +
    + @csrf @method('DELETE') + +
    +
    +
    +
    + +
    Bölüm bulunamadı
    +

    Filtrelerinizi değiştirin veya yeni bölüm ekleyin.

    + + Bölüm Ekle + +
    +
    +
    +
    + +
    + {{ $episodes->links('admin.partials.pagination') }} +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/genres/index.blade.php b/resources/views/admin/genres/index.blade.php new file mode 100644 index 0000000..626837e --- /dev/null +++ b/resources/views/admin/genres/index.blade.php @@ -0,0 +1,173 @@ +@extends('admin.layouts.app') +@section('title', 'Türler') +@section('page-title', 'Türler') + +@section('content') + + + +
    + + {{-- ── Sol: Ekle ── --}} +
    +
    +
    + +
    Yeni Tür Ekle
    +
    +
    +
    + @csrf +
    + + +
    +
    + +
    + + Kategori kartlarında görünecek renk +
    +
    + +
    +
    +
    +
    + + {{-- ── Sağ: Liste ── --}} +
    +
    +
    + + + + + + + + + + + + @forelse($genres as $genre) + + + + + + + + @empty + + + + @endforelse + +
    TürRenkAnime SayısıDurumİşlem
    +
    +
    + {{ $genre->name }} +
    +
    + @if($genre->color) + {{ $genre->color }} + @else + + @endif + + {{ $genre->animes_count }} + + @if($genre->is_active) + Aktif + @else + Pasif + @endif + +
    + +
    + @csrf @method('DELETE') + +
    +
    +
    +
    + +
    Henüz tür eklenmemiş
    +

    Sol panelden ilk türü ekleyebilirsiniz.

    +
    +
    +
    +
    +
    + +
    + +{{-- Edit Modal --}} + + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/health/index.blade.php b/resources/views/admin/health/index.blade.php new file mode 100644 index 0000000..03c7141 --- /dev/null +++ b/resources/views/admin/health/index.blade.php @@ -0,0 +1,373 @@ +@extends('admin.layouts.app') +@section('title', 'Sağlık Kontrolü') +@section('page-title', 'Sağlık Kontrolü') + +@push('styles') + +@endpush + +@section('content') + + + +{{-- Özet Kartlar --}} +
    + @php + $checks = [ + ['icon'=>'bi-copy','label'=>'Duplike Anime','value'=>$malDuplicates->count(),'danger'=>$malDuplicates->count()>0], + ['icon'=>'bi-shuffle','label'=>'Karışık Kaynaklı','value'=>$mixedSources->count(),'warn'=>$mixedSources->count()>0], + ['icon'=>'bi-collection-play','label'=>'Eksik Bölümlü','value'=>$missingEpisodes->count(),'warn'=>$missingEpisodes->count()>0], + ['icon'=>'bi-cloud','label'=>'Harici CDN Bölüm','value'=>number_format($externalCount),'info'=>$externalCount>0], + ['icon'=>'bi-trash3','label'=>'0 Bölümlü Anime','value'=>$zeroEpisodeAnimes->count(),'danger'=>$zeroEpisodeAnimes->count()>0], + ]; + @endphp + @foreach($checks as $c) +
    +
    +
    +
    +
    {{ $c['value'] }}
    +
    {{ $c['label'] }}
    +
    +
    + +
    +
    +
    +
    + @endforeach +
    + +{{-- 1. Duplike --}} +
    +
    + + Duplike Animeler (MAL ID) + {{ $malDuplicates->count() }} +
    +
    + @forelse($malDuplicates as $dup) +
    +
    MAL ID: {{ $dup['mal_id'] }}
    +
    +
    + + + + @foreach($dup['animes'] as $anime) + + + + + + + + @endforeach + +
    IDBaşlıkBölümEklenme
    #{{ $anime->id }} + + {{ $anime->title }} + + {{ $anime->episodes_count }}{{ $anime->created_at->format('d.m.Y') }} +
    + @csrf @method('DELETE') + +
    +
    +
    +
    +
    + @empty +
    Duplike anime yok.
    + @endforelse +
    +
    + +{{-- 2. Karışık Kaynak --}} +
    +
    + + Karışık Kaynaklı Animeler + (hem animecix hem anizium) + {{ $mixedSources->count() }} +
    +
    + @forelse($mixedSources as $item) +
    +
    + + {{ $item['anime']->title }} + +
    + @foreach($item['sources'] as $src => $cnt) + {{ $src }}: {{ $cnt }} bölüm + @endforeach +
    +
    +
    + @foreach($item['sources'] as $src => $cnt) +
    + @csrf + + +
    + @endforeach +
    +
    + @empty +
    Karışık kaynaklı anime yok.
    + @endforelse +
    +
    + +{{-- 3. Eksik Bölümler --}} +
    +
    + + Eksik Bölümlü Animeler + {{ $missingEpisodes->count() }} +
    +
    + @if($missingEpisodes->count()) +
    + + + + @foreach($missingEpisodes as $item) + + + + + + + + @endforeach + +
    AnimeBeklenenMevcutEksik
    {{ $item['title'] }}{{ $item['expected'] }}{{ $item['actual'] }}-{{ $item['missing'] }} + + Bölümler + +
    +
    + @else +
    Eksik bölümlü anime yok.
    + @endif +
    +
    + +{{-- 4. Sıfır Bölümlü --}} +
    +
    + + 0 Bölümlü Animeler + {{ $zeroEpisodeAnimes->count() }} + @if($zeroEpisodeAnimes->count() > 0) + + @endif +
    +
    + @if($zeroEpisodeAnimes->count()) +
    + + + + @foreach($zeroEpisodeAnimes as $anime) + + + + + + @endforeach + +
    AnimeEklenmeİşlem
    {{ $anime->title }}{{ $anime->created_at->format('d.m.Y') }} +
    + @csrf @method('DELETE') + +
    +
    +
    + @else +
    0 bölümlü anime yok.
    + @endif +
    +
    + +{{-- ── Depolama & İnode Yönetimi ── --}} +
    +
    + +
    Depolama & İnode Temizliği
    + +
    +
    + +
    + + İnode uyarısı: Sunucuda 188.000+ inode kullanılıyor. En büyük tüketiciler: + HLS segment cache, file session'lar, Laravel cache dosyaları. +
    + +
    +
    +
    +
    HLS Segment Cache
    +
    +
    +
    +
    +
    Session Dosyaları
    +
    +
    +
    +
    +
    Laravel Cache
    +
    — views
    +
    +
    + +
    + + + + +
    + + + +
    + Session sürücüsünü değiştir → .env +
    + File session'lar her ziyaretçi için 1 inode kullanır. Değiştirmek için .env dosyasında: +
    + SESSION_DRIVER=cookie +
    Cookie driver: 0 sunucu dosyası, anında inode tasarrufu. Sonra cache temizle.
    +
    + +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/import/index.blade.php b/resources/views/admin/import/index.blade.php new file mode 100644 index 0000000..1198d66 --- /dev/null +++ b/resources/views/admin/import/index.blade.php @@ -0,0 +1,528 @@ +@extends('admin.layouts.app') +@section('title', 'Anime İçe Aktar') +@section('page-title', 'Anime İçe Aktar') + +@push('styles') + +@endpush + +@section('content') + + + +
    + + {{-- ── Sol: Form ── --}} +
    + +
    +
    + +
    Yeni Import Job Oluştur
    +
    +
    +
    + @csrf + +
    + + +
    Anizium watch veya anime sayfası URL'si
    +
    + +
    + + +
    + +
    + + +
    + URL'den al: f.aniziumserver.sbs/85937/1/1/... +
    +
    + +
    + +
    + @php $oldRanges = old('season_ranges',[['season'=>1,'from'=>1,'to'=>'']]) @endphp + @foreach($oldRanges as $i => $range) +
    +
    + +
    + + + + +
    + @endforeach +
    + +
    + + +
    +
    +
    + + {{-- Test --}} +
    +
    + +
    Bağlantı Testi
    +
    +
    +
    + + +
    +
    +
    +
    + + {{-- ── Araçlar: Çift Kaynak + Altyazı Fix ── --}} +
    +
    + +
    Çift Kaynak Araçları
    +
    +
    + + {{-- İstatistikler --}} +
    +
    +
    İki kaynağı olan bölüm
    +
    +
    +
    +
    Altyazı hatası
    +
    {{ $stats['subtitle_mismatch'] }}
    +
    +
    +
    Sadece Anizium
    +
    +
    +
    +
    Sadece AnimeCix
    +
    +
    +
    + + +
    + + {{-- Altyazı Fix --}} +
    +
    + + Altyazı Uyuşmazlık Düzelt +
    +
    + Anizium'dan yanlış bölümün altyazısının kaydedildiği satırları siler. + Bot sonraki çalışmada doğru altyazıları yeniden indirir. +
    +
    + + +
    + +
    + +
    + + {{-- Re-queue --}} +
    +
    + + Yeniden Kuyruğa Al +
    +
    + Done job'ları tekrar pending yaparak botların her iki kaynaktan + video_sources eklemesini sağlar. +
    +
    +
    + + +
    +
    +
    + + +
    + +
    + +
    +
    + + {{-- Yardım --}} +
    +
    + +
    Nasıl Kullanılır?
    +
    +
    +
    Çift Kaynak Akışı
    +
      +
    1. Local: python reimport_all.py çalıştır
    2. +
    3. Local: python anizium_scraper/bot2_upload.py --daemon
    4. +
    5. Local: python animecix_scraper/daemon.py
    6. +
    7. Her bölüm otomatik "4K" + çevirmen seçeneğiyle gelir
    8. +
    +
    CDN ID Nasıl Bulunur?
    +
      +
    1. Anizium'da herhangi bir bölümü oynat
    2. +
    3. F12 → Network → master.m3u8 ara
    4. +
    5. URL'den sayıyı al: 85937
    6. +
    +
    +
    + +
    + + {{-- ── Sağ: Job Listesi ── --}} +
    +
    +
    + +
    Import Geçmişi
    + {{ $jobs->total() }} job + +
    + + {{-- Toplu silme paneli --}} + +
    + @forelse($jobs as $job) +
    +
    +
    +
    + {{ $job->anime_title ?: 'Anime CDN #'.$job->cdn_id }} +
    +
    + CDN: {{ $job->cdn_id }} + @if($job->season_ranges) +  ·  + @foreach($job->season_ranges as $r) + S{{ $r['season'] }} E{{ $r['from'] }}-{{ $r['to'] }}{{ !$loop->last ? ', ' : '' }} + @endforeach + @endif +
    +
    +
    + {{ $job->status_label }} + + + +
    + @csrf @method('DELETE') + +
    +
    +
    + + @if($job->total_episodes > 0) +
    +
    + {{ $job->done_episodes }}/{{ $job->total_episodes }} bölüm + %{{ $job->progress_percent }} +
    +
    +
    +
    +
    + @endif + + @if($job->current_step) +
    {{ $job->current_step }}
    + @endif + +
    {{ $job->created_at->format('d.m.Y H:i') }}
    +
    + @empty +
    + +
    Henüz import job yok
    +

    Sol panelden yeni bir import başlatın.

    +
    + @endforelse + +
    + {{ $jobs->links('admin.partials.pagination') }} +
    +
    +
    +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/import/show.blade.php b/resources/views/admin/import/show.blade.php new file mode 100644 index 0000000..2f5d015 --- /dev/null +++ b/resources/views/admin/import/show.blade.php @@ -0,0 +1,104 @@ +@extends('admin.layouts.app') +@section('title', 'Import #' . $import->id) +@section('page-title', 'Import #' . $import->id) + +@section('content') + + +
    +
    +
    +
    Import Detayı
    + + + + + + + + + + + + @if($import->anime) + + + + + @endif +
    Anime{{ $import->anime_title ?: 'Anime #'.$import->watch_id }}
    Watch ID{{ $import->watch_id }}
    URL{{ $import->source_url }}
    Durum{{ $import->status_label }}
    Bölümler{{ $import->done_episodes }} / {{ $import->total_episodes }}
    Hatalı{{ $import->failed_episodes }}
    Oluşturulma{{ $import->created_at->format('d.m.Y H:i') }}
    Anime Kaydı{{ $import->anime->title }}
    +
    + + @if($import->total_episodes > 0) +
    +
    + İlerleme + %{{ $import->progress_percent }} +
    +
    +
    +
    +
    {{ $import->done_episodes }}/{{ $import->total_episodes }} bölüm tamamlandı
    +
    + @endif + + @if($import->current_step) +
    +
    Şu An
    +

    {{ $import->current_step }}

    +
    + @endif + + @if($import->error_log) +
    +
    Hata Logu
    +
    {{ $import->error_log }}
    +
    + @endif + +
    + @csrf @method('DELETE') + +
    +
    + +
    +
    +
    Bu Job İçin Python Komutları
    +

    Sadece bu job'u çalıştırmak için:

    +
    cd C:\Users\yusuf\Desktop\anizium_scraper
    +python bot.py --job-id {{ $import->id }}
    + +

    Tüm bekleyen job'ları çalıştırmak için:

    +
    python bot.py
    + + @if($import->status === 'pending') +
    + Bu job bekliyor. Python script'i başlatın. +
    + @elseif($import->status === 'done') +
    + Tamamlandı! + @if($import->anime) + Anime'yi görüntüle → + @endif +
    + @elseif(in_array($import->status, ['downloading', 'uploading', 'fetching'])) +
    + İşlem devam ediyor... Sayfayı yenile. +
    + @endif +
    +
    +
    +@endsection +@push('scripts') +@if(in_array($import->status, ['fetching', 'downloading', 'uploading'])) + +@endif +@endpush diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php new file mode 100644 index 0000000..61f4438 --- /dev/null +++ b/resources/views/admin/layouts/app.blade.php @@ -0,0 +1,1330 @@ + + + + + + +@yield('title', 'Panel') — Animexe Admin +@php $__fav = \App\Models\Setting::get('site_favicon','') @endphp +@if($__fav)@else@endif + + + + + + +@stack('styles') + + + +{{-- Toast container --}} +
    + +{{-- Sidebar overlay --}} +
    + +{{-- ══ Sidebar ══ --}} + + +{{-- ══ Topbar ══ --}} +
    + +
    @yield('page-title', 'Dashboard')
    +
    + + + + + + +
    +
    +
    {{ strtoupper(substr(auth()->user()->name ?? 'A', 0, 1)) }}
    + {{ auth()->user()->name }} +
    +
    +
    + +{{-- ══ Main ══ --}} +
    +
    + @yield('content') +
    +
    + + + +@stack('scripts') + + diff --git a/resources/views/admin/mobile/index.blade.php b/resources/views/admin/mobile/index.blade.php new file mode 100644 index 0000000..9605a0b --- /dev/null +++ b/resources/views/admin/mobile/index.blade.php @@ -0,0 +1,178 @@ +@extends('admin.layouts.app') +@section('title', 'Mobil Uygulama') +@section('page-title', 'Mobil Uygulama Yönetimi') + +@section('content') + +{{-- Bakım modu uyarısı --}} +@if($settings['mobile_maintenance_mode'] == '1') +
    + +
    Bakım Modu AKTİF — Kullanıcılar uygulamaya giremez!
    +
    +@endif + +{{-- Stats --}} +
    +
    +
    +
    {{ number_format($stats['total_users']) }}
    +
    Kayıtlı Kullanıcı
    +
    +
    +
    +
    +
    {{ number_format($stats['active_30d']) }}
    +
    Aktif (30 gün)
    +
    +
    +
    +
    +
    {{ number_format($stats['fcm_tokens']) }}
    +
    Push Token
    +
    +
    +
    +
    +
    {{ number_format($stats['notifs_unread']) }}
    +
    Okunmamış
    +
    +
    +
    +
    +
    {{ number_format($stats['notifications_sent']) }}
    +
    Toplam Bildirim
    +
    +
    +
    +
    +
    {{ number_format($stats['notifs_today']) }}
    +
    Bugün Gönderilen
    +
    +
    +
    + +
    + + {{-- ── Sol: Ayarlar ── --}} +
    +
    +
    + +
    Uygulama Ayarları
    +
    +
    +
    + @csrf + + {{-- Bakım Modu --}} +
    +
    +
    +
    + Bakım Modu +
    +
    + Açıldığında tüm kullanıcılar uygulamaya giremez. +
    +
    +
    + + +
    +
    + + +
    + + {{-- Zorla Güncelleme --}} +
    +
    + Zorla Güncelleme +
    +
    + Minimum sürümü geçemeyen kullanıcılar zorunlu güncelleme ekranı görür. +
    +
    +
    + + +
    Bu sürümden eskiler güncellemeye zorlanır
    +
    +
    + + +
    Kullanıcılara gösterilen güncel sürüm
    +
    +
    + + +
    Boş bırakılırsa Play Store'a yönlendirir
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + {{-- ── Sağ: Durum & API ── --}} +
    + +
    +
    + +
    Anlık Durum
    +
    +
    + @foreach([ + ['Uygulama', $settings['mobile_maintenance_mode']=='1' ? 'Bakımda' : 'Çalışıyor'], + ['Minimum Sürüm', ''.$settings['mobile_min_version'].''], + ['Güncel Sürüm', ''.$settings['mobile_current_version'].''], + ['Push Token\'lı Cihaz', ''.number_format($stats['fcm_tokens']).''], + ] as [$lbl, $val]) +
    + {{ $lbl }} + {!! $val !!} +
    + @endforeach +
    +
    + +
    +
    + +
    API Endpoint
    +
    +
    +
    Uygulama başlangıcında kontrol:
    + GET /api/app-status +
    Örnek yanıt:
    +
    {
    +  "maintenance": false,
    +  "min_version": "1.0.0",
    +  "current_version": "1.0.0",
    +  "force_update": false
    +}
    +
    +
    + +
    +
    + +@endsection diff --git a/resources/views/admin/moderators/edit.blade.php b/resources/views/admin/moderators/edit.blade.php new file mode 100644 index 0000000..53b7b9b --- /dev/null +++ b/resources/views/admin/moderators/edit.blade.php @@ -0,0 +1,133 @@ +@extends('admin.layouts.app') +@section('title', 'İzin Düzenle — ' . $moderator->name) +@section('page-title', $moderator->name . ' — İzin Yönetimi') + +@push('styles') + +@endpush + +@section('content') + +
    + + Geri Dön + +
    +
    {{ $moderator->email }}
    +
    +
    + +
    +@csrf + +
    +
    +
    + {{ strtoupper(substr($moderator->name,0,1)) }} +
    +
    +
    {{ $moderator->name }}
    +
    + {{ count($permissions) }} aktif izin +
    +
    +
    +
    + + + +
    +
    + +
    + @foreach($groups as $groupName => $keys) + @php $groupGranted = collect(array_keys($keys))->filter(fn($k) => isset($permissions[$k]))->count(); @endphp +
    +
    +
    + +
    {{ $groupName }}
    + + {{ $groupGranted }}/{{ count($keys) }} + + +
    +
    + @foreach($keys as $key => $label) +
    +
    +
    {{ $label }}
    + {{ $key }} +
    +
    + +
    +
    + @endforeach +
    +
    +
    + @endforeach +
    + +
    + + + +
    + +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/moderators/index.blade.php b/resources/views/admin/moderators/index.blade.php new file mode 100644 index 0000000..3c09276 --- /dev/null +++ b/resources/views/admin/moderators/index.blade.php @@ -0,0 +1,189 @@ +@extends('admin.layouts.app') +@section('title', 'Moderatör Yönetimi') +@section('page-title', 'Moderatör Yönetimi') + +@section('content') + + + +{{-- Tablo --}} +
    +
    + + + + + + + + + + + + @forelse($moderators as $mod) + @php + $perms = $mod->moderatorPermissions->pluck('permission'); + $grantedGroups = collect($groups)->filter(fn($keys) => collect(array_keys($keys))->intersect($perms)->isNotEmpty()); + @endphp + + + + + + + + @empty + + + + @endforelse + +
    Kullanıcıİzin Sayısıİzin GruplarıKatılım Tarihiİşlem
    +
    +
    + {{ strtoupper(substr($mod->name,0,1)) }} +
    +
    +
    {{ $mod->name }}
    +
    {{ $mod->email }}
    +
    +
    +
    + + {{ $mod->moderator_permissions_count }} izin + + +
    + @foreach($grantedGroups->keys() as $gName) + {{ $gName }} + @endforeach + @if($grantedGroups->isEmpty()) + — izin yok — + @endif +
    +
    {{ $mod->created_at->format('d.m.Y') }} +
    + + İzinler + +
    + @csrf + +
    +
    +
    +
    + +
    Henüz moderatör yok
    +

    Kullanıcıları moderatör olarak atayın.

    +
    +
    +
    +
    + +{{-- İzin Grupları --}} +
    +
    + +
    Mevcut İzin Grupları
    +
    +
    +
    + @foreach($groups as $groupName => $keys) +
    +
    +
    + {{ $groupName }} +
    + @foreach($keys as $key => $label) +
    + {{ $key }} + {{ $label }} +
    + @endforeach +
    +
    + @endforeach +
    +
    +
    + +{{-- Promote Modal --}} + + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/notifications/index.blade.php b/resources/views/admin/notifications/index.blade.php new file mode 100644 index 0000000..0e1a885 --- /dev/null +++ b/resources/views/admin/notifications/index.blade.php @@ -0,0 +1,243 @@ +@extends('admin.layouts.app') +@section('title', 'Bildirimler') +@section('page-title', 'Bildirim Yönetimi') + +@section('content') + +{{-- Stats --}} +
    +
    +
    +
    +
    +
    {{ number_format($stats['total']) }}
    +
    Toplam Bildirim
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['unread']) }}
    +
    Okunmamış
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['users']) }}
    +
    Kullanıcı
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($stats['today']) }}
    +
    Bugün Gönderildi
    +
    +
    +
    +
    +
    +
    + +
    + + {{-- ── Sol: Gönder ── --}} +
    +
    +
    + +
    Bildirim Gönder
    +
    +
    +
    + @csrf + +
    + + +
    + +
    + + +
    + {{ strlen(old('body','')) }}/500 karakter +
    +
    + +
    + + +
    + +
    + +
    + + + + +
    +
    + @foreach(['bi-megaphone-fill','bi-star-fill','bi-bell-fill','bi-gift-fill','bi-play-circle-fill','bi-trophy-fill','bi-info-circle-fill','bi-exclamation-triangle-fill'] as $ic) + + @endforeach +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + + {{-- ── Sağ: Son Bildirimler ── --}} +
    +
    +
    + +
    Son Bildirimler
    + Son 50 +
    +
    + + + + + + + + + + + + @forelse($recent as $n) + @php $d = $n->data; @endphp + + + + + + + + @empty + + + + @endforelse + +
    KullanıcıTürİçerikDurumTarih
    + {{ $n->user?->name ?? '—' }} + @if($n->user?->username) +
    @{{ $n->user->username }}
    + @endif +
    + @if($n->type === 'admin') + Admin + @elseif($n->type === 'episode') + Bölüm + @else + {{ $n->type }} + @endif + + @if($n->type === 'admin') +
    {{ $d['title'] ?? '' }}
    + @if(!empty($d['body'])) +
    {{ Str::limit($d['body'],55) }}
    + @endif + @elseif($n->type === 'episode') +
    {{ $d['anime_title'] ?? '' }}
    +
    {{ $d['episode_number'] ?? '' }}. Bölüm
    + @endif +
    + @if($n->read_at) + + Okundu + + @else + + Yeni + + @endif + {{ $n->created_at->format('d.m H:i') }}
    +
    + +

    Henüz bildirim gönderilmemiş.

    +
    +
    +
    +
    +
    + +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/partials/pagination.blade.php b/resources/views/admin/partials/pagination.blade.php new file mode 100644 index 0000000..28c4072 --- /dev/null +++ b/resources/views/admin/partials/pagination.blade.php @@ -0,0 +1,53 @@ +@if ($paginator->hasPages()) +
    +
    + {{ $paginator->firstItem() }}–{{ $paginator->lastItem() }} + / {{ number_format($paginator->total()) }} sonuç +
    + +
    +@endif diff --git a/resources/views/admin/permissions/index.blade.php b/resources/views/admin/permissions/index.blade.php new file mode 100644 index 0000000..710da3f --- /dev/null +++ b/resources/views/admin/permissions/index.blade.php @@ -0,0 +1,74 @@ +@extends('admin.layouts.app') +@section('title', 'Global İzin Ayarları') +@section('page-title', 'Global İzin Ayarları') + +@section('content') + +
    +
    + +
    +
    +
    + Genel Bilgi +
    +
    + Bu ayarlar tüm site için geçerlidir. Belirli anime veya bölümler için + anime/bölüm sayfasından override yapabilirsiniz. +
    +
    +
    + +
    +
    + +
    Global İzin Ayarları
    +
    +
    +
    + @csrf + @forelse($permissions as $perm) +
    +
    +
    {{ $perm->label }}
    + @if($perm->description) +
    {{ $perm->description }}
    + @endif +
    + {{ $perm->key }} +
    +
    +
    + required_membership === 'free' ? 'checked' : '' }}> + + + required_membership === 'premium' ? 'checked' : '' }}> + +
    +
    + @empty +
    + +
    İzin ayarı bulunamadı
    +

    Seeder'ı çalıştırdın mı?

    +
    + @endforelse + + @if($permissions->count() > 0) +
    + +
    + @endif +
    +
    +
    +
    +
    + +@endsection diff --git a/resources/views/admin/plans/create.blade.php b/resources/views/admin/plans/create.blade.php new file mode 100644 index 0000000..71bc4fb --- /dev/null +++ b/resources/views/admin/plans/create.blade.php @@ -0,0 +1,202 @@ +@extends('admin.layouts.app') +@section('title', 'Plan Ekle') +@section('page-title', 'Plan Ekle') + +@push('styles') + +@endpush + +@section('content') + + + +
    +
    +
    + @csrf + + {{-- Temel --}} +
    +
    + +
    Plan Bilgileri
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    0 = deneme yok
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    Kullanıcı "Satın Al" butonuna tıklayınca buraya yönlendirilir.
    +
    +
    + + +
    Plan kartının üstüne çıkar. Boş = rozet yok.
    +
    +
    + + +
    cyan, pink veya gold
    +
    +
    +
    +
    + + {{-- Özellikler --}} +
    +
    + +
    Görünür Özellikler
    + — Premium sayfasında listeler +
    +
    +
    +
    + + +
    +
    +
    +
    + + {{-- Perkler --}} +
    +
    + +
    Premium Özellikler (Perk'ler)
    +
    +
    +
    + Bu planda hangi premium özelliklerin aktif olacağını seçin. +
    + @foreach($allPerks as $category => $perks) +
    +
    {{ $category }}
    +
    + @foreach($perks as $key => $meta) +
    + +
    + @endforeach +
    +
    + @endforeach +
    +
    + + {{-- Görünürlük --}} +
    +
    + +
    Görünürlük Ayarları
    +
    +
    +
    +
    +
    +
    +
    Aktif
    +
    Plan kullanılabilir durumda
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    Herkese Görünür
    +
    Premium sayfasında listele
    +
    +
    + +
    +
    +
    +
    +
    + + +
    Bu tarihten sonra plan otomatik kaldırılır. Boş = süresiz.
    +
    +
    +
    + +
    + + İptal +
    +
    +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/plans/edit.blade.php b/resources/views/admin/plans/edit.blade.php new file mode 100644 index 0000000..d8af1bd --- /dev/null +++ b/resources/views/admin/plans/edit.blade.php @@ -0,0 +1,198 @@ +@extends('admin.layouts.app') +@section('title', 'Plan Düzenle') +@section('page-title', 'Plan Düzenle: ' . $plan->name) + +@push('styles') + +@endpush + +@section('content') + + + +
    +
    +
    + @csrf @method('PUT') + + {{-- Temel --}} +
    +
    + +
    Plan Bilgileri
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + +
    Kullanıcı "Satın Al" butonuna tıklayınca buraya yönlendirilir.
    +
    +
    + + +
    Plan kartının üstüne çıkar. Boş = rozet yok.
    +
    +
    + + +
    cyan, pink veya gold
    +
    +
    +
    +
    + + {{-- Özellikler --}} +
    +
    + +
    Görünür Özellikler
    +
    +
    +
    + @foreach(old('features',$plan->features??[]) as $f) +
    + + +
    + @endforeach +
    + +
    +
    + + {{-- Perkler --}} +
    +
    + +
    Premium Özellikler (Perk'ler)
    +
    +
    + @php $currentPerks = old('perks', array_keys(array_filter($plan->perks ?? []))); @endphp + @foreach($allPerks as $category => $perks) +
    +
    {{ $category }}
    +
    + @foreach($perks as $key => $meta) +
    + +
    + @endforeach +
    +
    + @endforeach +
    +
    + + {{-- Görünürlük --}} +
    +
    + +
    Görünürlük Ayarları
    +
    +
    +
    +
    +
    +
    +
    Aktif
    +
    Plan kullanılabilir
    +
    +
    + is_active?'checked':'' }}> +
    +
    +
    +
    +
    +
    +
    Herkese Görünür
    +
    Premium sayfasında listele
    +
    +
    + is_public??true)?'checked':'' }}> +
    +
    +
    +
    +
    + + +
    Bu tarihten sonra plan otomatik kaldırılır. Boş = süresiz.
    +
    +
    +
    + +
    + + İptal +
    +
    +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/plans/index.blade.php b/resources/views/admin/plans/index.blade.php new file mode 100644 index 0000000..84bb203 --- /dev/null +++ b/resources/views/admin/plans/index.blade.php @@ -0,0 +1,93 @@ +@extends('admin.layouts.app') +@section('title', 'Üyelik Planları') +@section('page-title', 'Üyelik Planları') + +@section('content') + + + +@if($plans->isEmpty()) +
    +
    +
    + +
    Henüz plan eklenmemiş
    +

    İlk üyelik planınızı oluşturun.

    + + Plan Ekle + +
    +
    +
    +@else +
    + @foreach($plans as $plan) +
    +
    +
    + {{-- Header --}} +
    +
    +
    {{ $plan->name }}
    +
    + {{ $plan->duration_days }} gün +
    +
    + + {{ $plan->is_active ? 'Aktif' : 'Pasif' }} + +
    + + {{-- Fiyat --}} +
    + {{ number_format($plan->price, 2) }} + +
    + + @if($plan->description) +
    + {{ $plan->description }} +
    + @endif + + {{-- Özellikler --}} + @if($plan->features) +
    + @foreach($plan->features as $feature) +
    + + {{ $feature }} +
    + @endforeach +
    + @endif + + {{-- Aksiyonlar --}} +
    + + Düzenle + +
    + @csrf @method('DELETE') + +
    +
    +
    +
    +
    + @endforeach +
    +@endif + +@endsection diff --git a/resources/views/admin/seo/index.blade.php b/resources/views/admin/seo/index.blade.php new file mode 100644 index 0000000..b905a83 --- /dev/null +++ b/resources/views/admin/seo/index.blade.php @@ -0,0 +1,2429 @@ +@extends('admin.layouts.app') +@section('title','SEO Paneli') +@section('page-title','SEO Paneli') + +@push('styles') + +@endpush + +@section('content') +
    + +{{-- Header --}} +
    +
    +

    SEO Paneli

    +

    Arama motoru optimizasyonu · Anahtar kelimeler · Yönlendirmeler · PageSpeed

    +
    + +
    + +@if(session('success')) +
    {{ session('success') }}
    +@endif + +{{-- SEO Score Summary --}} +
    +
    +
    +
    +
    + @php $sc = $audit['score']; $color = $sc>=80?'var(--success)':($sc>=60?'#d29922':'var(--danger)'); $circ = round(2*pi()*52*$sc/100,1); @endphp + + + + +
    +
    {{ $sc }}
    +
    / 100
    +
    +
    +
    SEO Skoru
    +
    @if($sc>=80)Mükemmel @elseif($sc>=60)Geliştirilmeli @else Kritik Sorunlar @endif
    +
    +
    +
    +
    +
    +
    {{ $audit['pass_count'] }}
    +
    Geçen Kontrol
    +
    {{ $audit['totals']['total'] }} anime yayında
    +
    +
    +
    +
    +
    {{ $audit['fail_count'] }}
    +
    Başarısız Kontrol
    +
    {{ $audit['totals']['noSeoTitle'] ?? 0 }} animede SEO başlık eksik
    +
    +
    +
    +
    + @php $cov = $animeSeoCoverage; $pct = $cov['total']>0?round($cov['has_seo_title']/$cov['total']*100):0; @endphp +
    {{ $pct }}%
    +
    Anime SEO Kapsama
    +
    +
    {{ $cov['has_seo_title'] }}/{{ $cov['total'] }} başlık · {{ $cov['has_seo_desc'] }}/{{ $cov['total'] }} açıklama
    +
    +
    +
    + +{{-- Tab Nav --}} +
    + + + + + + + + + + + + +
    + +{{-- ═══════ TAB 1: META ═══════ --}} +
    +@csrf +
    +
    +
    +
    +
    Başlık Ayarları
    +
    +
    + + +
    OG etiketlerinde ve sayfa başlıklarında kullanılır.
    +
    +
    + + +
    %s = sayfa adı. Örn: Naruto — Animexe | Türkçe Anime İzle
    +
    +
    + + +
    +
    50–60 karakter ideal.
    +
    {{ strlen($settings['seo_home_title'] ?? '') }}/70
    +
    +
    +
    + + +
    +
    120–160 karakter ideal.
    +
    {{ strlen($settings['seo_home_description'] ?? '') }}/320
    +
    +
    +
    + + +
    Virgülle ayrılmış. Ör: anime izle, türkçe anime
    +
    +
    + + +
    Trailing slash olmadan. Canonical URL'ler için kullanılır.
    +
    +
    +
    + +
    +
    Noindex Kuralları
    +
    +
    +
    + + +
    + Önerilen +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    Google Arama Önizlemesi
    +
    +
    +
    animexe.com
    +
    {{ $settings['seo_home_title'] ?? 'Animexe — Türkçe Anime İzle' }}
    +
    {{ Str::limit($settings['seo_home_description']??'',155) }}
    +
    +
    +
    +
    +
    Mobil Snippet
    +
    +
    +
    animexe.com ›
    +
    {{ $settings['seo_home_title']??'' }}
    +
    {{ Str::limit($settings['seo_home_description']??'',100) }}
    +
    +
    +
    +
    +
    Karakter Kılavuzu
    +
    + @foreach(['Sayfa Başlığı'=>['guide_title','bar_title',50,60,70],'Meta Açıklama'=>['guide_desc','bar_desc',120,160,320]] as $lbl=>$cfg) +
    +
    + {{ $lbl }}0/{{ $cfg[4] }} +
    +
    +
    + @endforeach +
    +
    +
    +
    + +
    +
    {{-- #seo-meta-form --}} + +{{-- ═══════ TAB 2: ANAHTAR KELİMELER ═══════ --}} +
    +
    +
    +
    +
    Yeni Anahtar Kelime Ekle
    +
    +
    + @csrf +
    + + +
    +
    + + +
    Bu kelimeyi optimize etmek istediğiniz sayfa
    +
    +
    +
    + + +
    +
    + + +
    0=kolay, 100=çok zor
    +
    +
    +
    + + +
    + +
    +
    +
    +
    + +
    +
    +
    Anahtar Kelime Listesi ({{ $keywords->count() }})
    + @if($keywords->isEmpty()) +
    + + Henüz anahtar kelime eklenmedi. Sol taraftan ekleyin. +
    + @else +
    + + + + + + + + + + + + @foreach($keywords as $kw) + + + + + + + + @endforeach + +
    KelimeHedef URLHacimRekabet
    +
    {{ $kw->keyword }}
    + @if($kw->notes)
    {{ Str::limit($kw->notes,60) }}
    @endif +
    + @if($kw->target_url) + {{ Str::limit($kw->target_url,30) }} + @else@endif + + @if($kw->search_volume !== null) + {{ number_format($kw->search_volume) }} + @else@endif + + @if($kw->difficulty !== null) + @php $dc = $kw->difficulty<=33?'var(--success)':($kw->difficulty<=66?'#d29922':'var(--danger)'); @endphp +
    {{ $kw->difficulty }}
    +
    + @else@endif +
    +
    + @csrf @method('DELETE') + +
    +
    +
    + @endif +
    + +
    +
    Sıralama İpuçları
    +
    +
    + @foreach([ + ['bi-1-circle','Anime Türkiye Odaklı','anime izle, türkçe anime, ücretsiz anime gibi yerel arama kelimelerine odaklanın'], + ['bi-2-circle','Long-tail Kelimeler','naruto türkçe altyazılı izle, attack on titan dublaj gibi uzun kuyruklu kelimeler daha az rekabetçi'], + ['bi-3-circle','İçerik Sıklığı','Popüler animeler için haftada en az 1 bölüm ekleyin. Google taze içeriği sever'], + ['bi-4-circle','Internal Linking','Anime sayfalarında benzer türlere link verin. Dahili bağlantı SEO skorunu artırır'], + ] as $tip) +
    +
    +
    + + {{ $tip[1] }} +
    +
    {{ $tip[2] }}
    +
    +
    + @endforeach +
    +
    +
    +
    +
    +
    + +{{-- ═══════ TAB 3: ANİME SEO ═══════ --}} +
    + +{{-- ── Toplu Doldurma Paneli ── --}} +
    +
    + +
    Toplu AI Doldurma
    + Yükleniyor... +
    +
    + + {{-- Coverage istatistikleri --}} +
    +
    +
    +
    SEO Başlığı Var
    +
    +
    +
    +
    Açıklama Var
    +
    +
    +
    +
    SEO Eksik Anime
    +
    +
    +
    +
    Meta Eksik Anime
    +
    +
    + + {{-- Aksiyon butonları --}} +
    + +
    +
    + Hızlı SEO +
    +
    Template kullanır, AI çağrısı yok. Tüm anime birkaç saniyede tamamlanır.
    + +
    + +
    +
    + AI SEO +
    +
    DeepSeek ile her anime için özgün SEO başlığı ve açıklama üretir.
    + +
    + +
    +
    + Tam Doldurma +
    +
    Açıklama + Yıl + Stüdyo + Türler + SEO başlığı — her şeyi AI ile doldurur.
    + +
    + +
    + +
    + + +
    + + {{-- Progress alanı --}} + + +
    +
    + +{{-- ── Tablo başlığı ── --}} +
    +
    +
    Anime SEO Başlıkları & Açıklamaları
    +

    Tek tek anime için düzenle ve kaydet. Toplu işlem için yukarıdaki paneli kullan.

    +
    +
    + +
    +@csrf +
    +
    + + + + + + + + + + + + @foreach($animes as $a) + + + + + + + + + @endforeach + +
    AnimeSEO Başlığı (max 70 karakter)Meta Açıklaması (max 160 karakter)Anahtar Kelimeler
    +
    {{ Str::limit($a->title,30) }}
    +
    /anime/{{ $a->slug }}
    + @if(!$a->seo_title) + SEO eksik + @else + SEO var + @endif +
    + + + + + + + +
    +
    +
    +
    {{ $animes->links() }}
    + +
    +
    +
    +
    + +{{-- ═══════ TAB 4: SOSYAL MEDYA ═══════ --}} +
    +@csrf +
    +
    +
    +
    +
    Open Graph (Facebook / WhatsApp / LinkedIn)
    +
    +
    + + +
    1200×630px önerilir. HTTPS URL veya /public altı yol.
    +
    +
    + + +
    +
    +
    +
    +
    Twitter / X Card
    +
    +
    + +
    + @ + +
    +
    +
    +
    +
    +
    +
    +
    Organizasyon Bilgileri (Schema)
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    {{-- social form --}} + +{{-- ═══════ TAB 5: YAPISAL VERİ ═══════ --}} +
    +@csrf + +
    +
    +
    +
    +
    Schema.org Ayarları
    +
    +
    +
    + + +
    + Önerilen +
    +
    + + +
    +
    +
    + + +
    + Rich Snippet +
    +
    +
    + + +
    + Rich Snippet +
    +
    +
    +
    +
    +
    +
    Schema Türleri Hakkında
    +
    + @foreach([ + ['Organization','Site kimliği için. Google\'ın sitenizi tanımasını sağlar.','bg-success'], + ['TVSeries / Movie','Her anime için. IMDB benzeri rich snippet\'ler oluşturur.','bg-success'], + ['BreadcrumbList','Navigasyon yolu. Arama sonuçlarında alt bağlantılar gösterir.','bg-info text-dark'], + ['VideoObject','Bölüm sayfaları için. Video thumbnails arama sonuçlarında çıkar.','bg-warning text-dark'], + ['FAQPage','Anime SSS bölümü. Arama sonuçlarında soru-cevap kutucukları gösterir.','bg-warning text-dark'], + ] as $s) +
    + {{ $s[0] }} + {{ $s[1] }} +
    + @endforeach +
    +
    +
    +
    + +
    + +{{-- ═══════ TAB 6: SİTEMAP ═══════ --}} +
    +
    +
    +
    +
    Sitemap İstatistikleri
    +
    +
    + @foreach([['Anime','bi-play-circle',$sitemapStats['anime_count'],'#58a6ff'],['Tür','bi-tags',$sitemapStats['genre_count'],'var(--success)'],['Statik','bi-file-text',2,'var(--text2)']] as $s) +
    +
    + +
    {{ $s[2] }}
    +
    {{ $s[0] }}
    +
    +
    + @endforeach +
    + @if($sitemapStats['last_updated']) +
    Son güncelleme: {{ $sitemapStats['last_updated'] }}
    + @endif +
    +
    +
    +
    Arama Motorlarına Bildir
    +
    + @if(session('ping_results')) +
    + @foreach(session('ping_results') as $e=>$r) + {{ ucfirst($e) }} + @endforeach +
    + @endif + + @csrf + + +
    sitemap.xml güncellendiğinde arama motorlarını haberdar eder.
    +
    +
    +
    +
    +
    +
    Sitemap Önizleme
    +
    +
    + @php $previewAnimes = \App\Models\Anime::where('is_published',true)->orderByDesc('updated_at')->take(15)->get(['title','slug','updated_at']); @endphp + @foreach($previewAnimes as $pa) +
    + /anime/{{ $pa->slug }} + {{ $pa->updated_at?->format('Y-m-d') }} +
    + @endforeach +
    ... ve daha fazlası
    +
    + +
    +
    +
    +
    +
    + +{{-- ═══════ TAB 7: ROBOTS.TXT ═══════ --}} +
    +{{-- ana form burada biter --}} +
    +
    +
    +
    robots.txt Düzenle
    +
    +
    + @csrf + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    robots.txt Rehberi
    +
    + @foreach([ + ['Disallow: /admin','Admin panelini tara engelleyin. Güvenlik ve bant genişliği.'], + ['Disallow: /api','API rotaları indexlenmemeli.'], + ['Allow: /','Her şeye izin ver (varsayılan).'], + ['Sitemap: https://...','Sitemap konumunu belirtin. Mecburi değil ama önerilir.'], + ['User-agent: Googlebot','Sadece Google botu için kural yazın.'], + ['Crawl-delay: 5','Bot tarama hızını saniye cinsinden sınırlayın.'], + ] as $r) +
    + {{ $r[0] }} +
    {{ $r[1] }}
    +
    + @endforeach +
    +
    +
    +
    + + +{{-- ═══════ TAB 8: YÖNLENDİRMELER ═══════ --}} +
    +
    +
    +
    +
    +
    Yeni Yönlendirme Ekle
    +
    +
    + @csrf +
    + + +
    Tam yol girin (/ile başlayan)
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    Aktif Yönlendirmeler ({{ $redirects->total() }})
    + @if($redirects->isEmpty()) +
    + + Henüz yönlendirme eklenmedi. +
    + @else +
    + + + + + + @foreach($redirects as $rd) + + + + + + + + + @endforeach + +
    KaynakHedefTürİsabetDurum
    {{ $rd->from_path }}{{ Str::limit($rd->to_path,40) }}{{ $rd->type }}{{ number_format($rd->hits) }} + + +
    + @csrf @method('DELETE') + +
    +
    +
    +
    {{ $redirects->links() }}
    + @endif +
    +
    +
    +
    + +{{-- ═══════ TAB 9: ANALİTİK & ARAÇLAR ═══════ --}} +
    + +{{-- ── Integration Status Banner ─────────────────────────────────────── --}} +
    +
    Entegrasyon Durumu
    +
    +
    + + Google Analytics 4 + {{ $integrations['ga4'] ? ($settings['seo_google_analytics']??'') : 'Bağlı değil' }} +
    +
    + + Tag Manager + {{ $integrations['gtm'] ? ($settings['seo_gtm_id']??'') : 'Bağlı değil' }} +
    +
    + + Search Console + {{ $integrations['gsc'] ? 'Doğrulandı' : 'Bağlı değil' }} +
    +
    + + Bing Webmaster + {{ $integrations['bing'] ? 'Doğrulandı' : 'Bağlı değil' }} +
    +
    + + Yandex + {{ $integrations['yandex'] ? 'Doğrulandı' : 'Bağlı değil' }} +
    +
    +
    + +{{-- ── KPI Cards Row ─────────────────────────────────────────────────── --}} +
    +
    +
    +
    +
    +
    {{ number_format($analyticsStats['total_anime']) }}
    +
    Toplam Anime
    + @if($analyticsStats['new_anime_this_month'] > 0) +
    +{{ $analyticsStats['new_anime_this_month'] }} bu ay
    + @endif +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($analyticsStats['total_users']) }}
    +
    Toplam Üye
    + @if($analyticsStats['new_users_this_month'] > 0) +
    +{{ $analyticsStats['new_users_this_month'] }} bu ay
    + @endif +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($analyticsStats['total_episodes']) }}
    +
    Toplam Bölüm
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($analyticsStats['total_comments']) }}
    +
    Toplam Yorum
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($analyticsStats['total_watchlists']) }}
    +
    İzleme Listesi
    +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($analyticsStats['total_ratings']) }}
    +
    Toplam Rating
    +
    +
    +
    +
    + +{{-- ── SEO Coverage + Redirect Stats ───────────────────────────────────── --}} +
    +
    +
    +
    SEO Kapsama & Sağlık Skoru
    +
    +
    +
    + @php + $overallScore = round(($analyticsStats['seo_title_pct'] + $analyticsStats['seo_desc_pct']) / 2); + $scoreColor = $overallScore >= 80 ? 'var(--success)' : ($overallScore >= 50 ? '#d29922' : 'var(--danger)'); + $circumference = 2 * pi() * 52; + $dashOffset = $circumference * (1 - $overallScore / 100); + @endphp +
    + + + + +
    +
    {{ $overallScore }}
    +
    SEO Skoru
    +
    +
    +
    {{ $analyticsStats['total_anime'] }} anime üzerinden
    +
    +
    +
    +
    + SEO Başlık Kapsama + {{ $analyticsStats['seo_title_pct'] }}% +
    +
    +
    +
    +
    {{ $animeSeoCoverage['has_seo_title'] }} / {{ $animeSeoCoverage['total'] }} anime
    +
    +
    +
    + SEO Açıklama Kapsama + {{ $analyticsStats['seo_desc_pct'] }}% +
    +
    +
    +
    +
    {{ $animeSeoCoverage['has_seo_desc'] }} / {{ $animeSeoCoverage['total'] }} anime
    +
    +
    +
    +
    +
    {{ $analyticsStats['total_genres'] }}
    +
    Aktif Tür
    +
    +
    +
    +
    +
    {{ $analyticsStats['total_blog_posts'] }}
    +
    Blog Yazısı
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Yönlendirme İstatistikleri
    +
    +
    +
    +
    {{ $analyticsStats['total_redirects'] }}
    +
    Aktif Yönlendirme
    +
    +
    +
    {{ number_format($analyticsStats['total_redirect_hits']) }}
    +
    Toplam Tıklama
    +
    +
    +
    +
    +
    301 Kalıcı yönlendirmeler SEO güçlendirir
    +
    302 Geçici yönlendirmeler juice aktarmaz
    +
    Redirect zinciri oluşturmaktan kaçının
    +
    +
    +
    +
    +
    + +{{-- ── Quick Tool Links ─────────────────────────────────────────────────── --}} + + +{{-- ── Embed Dashboard ─────────────────────────────────────────────────── --}} +
    +
    +
    Gömülü Rapor Paneli (Looker Studio)
    + Looker Studio embed URL'sini girin +
    +
    +
    +
    + +
    Looker Studio → Dosya → Raporu göm → URL kopyala
    +
    +
    + + +
    +
    +
    + @if(!empty($settings['seo_looker_embed_url'] ?? '')) + + @else +
    + +
    Looker Studio URL giriniz ve Yükle'ye tıklayın
    +
    Ya da doğrudan GA4 Analytics panelini kullanın
    + + Google Analytics'i Aç + +
    + @endif +
    +
    +
    + +{{-- ── Settings Form ─────────────────────────────────────────────────── --}} +
    +@csrf +
    +
    +
    +
    +
    Tracking Kodları
    +
    +
    +
    +
    + + @if($integrations['ga4']) + + Aktif + + @else + + Kurulmadı + + @endif +
    +
    + + + + +
    +
    GA4 ölçüm kimliği. <head> içine otomatik eklenir.
    +
    +
    +
    + + @if($integrations['gtm']) + + Aktif + + @else + + Opsiyonel + + @endif +
    +
    + + + + +
    +
    GA4 ile birlikte kullanılabilir veya alternatif olarak.
    +
    +
    + +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    Webmaster Doğrulama Kodları
    +
    +
    +
    +
    + + @if($integrations['gsc']) + Doğrulandı + @else + Doğrulanmadı + @endif +
    +
    + + +
    +
    Search Console → Ayarlar → Site doğrulama → HTML etiketi yöntemi
    +
    +
    +
    + + @if($integrations['bing']) + Doğrulandı + @else + Kurulmadı + @endif +
    +
    + + +
    +
    +
    +
    + + @if($integrations['yandex']) + Doğrulandı + @else + Kurulmadı + @endif +
    +
    + + +
    +
    + {{-- GSC Setup Guide --}} +
    +
    Search Console Kurulum Adımları
    +
      +
    1. Search Console'u açın ve sitenizi ekleyin
    2. +
    3. "HTML etiketi" doğrulama yöntemini seçin
    4. +
    5. Verification içeriğini buraya yapıştırın ve kaydedin
    6. +
    7. Search Console'a dönüp "Doğrula" butonuna basın
    8. +
    9. Sitemap gönderin: {{ config('app.url') }}/sitemap.xml
    10. +
    +
    +
    +
    +
    +
    +
    + + @if($integrations['ga4']) + + GA4'ü Aç + + @endif + @if($integrations['gsc']) + + Search Console'u Aç + + @endif +
    +
    +
    + +{{-- ═══════ TAB 10: PAGESPEED ═══════ --}} +
    +
    +
    +
    +
    PageSpeed Insights Testi
    +
    +
    + @csrf + +
    + + +
    +
    Google Cloud Console'dan API anahtarı alın. PageSpeed Insights API'yi etkinleştirin.
    +
    + +
    + +
    + + +
    +
    + +
    + + +
    +
    + +
    +
    +
    +
    + + +
    +
    + +
    API anahtarınızı girin ve test başlatın.
    +
    Core Web Vitals, skor ve optimizasyon önerileri gösterilecek.
    +
    +
    +
    +
    +
    + +{{-- ═══════ TAB 11: DENETİM ═══════ --}} +
    +
    +
    +
    +
    +
    SEO Kontrol Listesi ({{ count($audit['checks']) }} kontrol)
    + +
    +
    + @foreach($audit['checks'] as $c) +
    + + {{ $c['pass']?$c['passMsg']:$c['failMsg'] }} + +{{ $c['weight'] }}p +
    + @endforeach +
    +
    +
    + +
    +
    +
    Yinelenen İçerik Denetimi
    +
    + @if($dupDesc > 0) +
    + + {{ $dupDesc }} grup tekrarlayan anime açıklaması tespit edildi. +
    + @else +
    + Tekrarlayan içerik tespit edilmedi. +
    + @endif + + +
    +
    + +
    +
    Dahili Bağlantı Analizi
    +
    +

    Hangi animeler diğer animelerin açıklamalarında hiç atıflanmıyor? (Orphaned pages)

    + + + +
    +
    + +
    +
    Hızlı Aksiyon
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +{{-- ═══════ TAB 12: AI ASISTAN ═══════ --}} +
    + +@php $aiConfigured = !empty(\App\Models\Setting::get('deepseek_api_key','')); @endphp + +@if(!$aiConfigured) +
    + +
    + DeepSeek API Anahtarı Gerekli
    + AI özelliklerini kullanmak için Ayarlar sayfasından DeepSeek API anahtarını ekleyin. +
    +
    +@endif + +
    + + {{-- Sol: Chat + Hızlı Araçlar --}} +
    + + {{-- AI Chat --}} +
    +
    + +
    AI SEO Danışmanı
    + DeepSeek +
    +
    +
    +
    + Merhaba! Ben Animexe'nin AI SEO danışmanıyım.

    + Size şu konularda yardımcı olabilirim:
    + • Anahtar kelime stratejisi
    + • Meta başlık/açıklama optimizasyonu
    + • Teknik SEO sorunları
    + • İçerik stratejisi ve rakip analizi
    + • Schema.org ve yapısal veri

    + Ne öğrenmek istersiniz? +
    +
    +
    +
    +
    + + +
    +
    + @foreach(['Anahtar kelime stratejisi öner','Teknik SEO sorunlarım neler?','Rakip analizi yap','Core Web Vitals nedir?','Schema nasıl eklenir?'] as $qp) + + @endforeach +
    +
    +
    +
    + + {{-- Sağ: AI Araçlar --}} +
    + + {{-- Anime AI SEO Üretici --}} +
    +
    + +
    AI Anime SEO Üretici
    + Kaliteli / AI Destekli +
    +
    +

    Seçtiğiniz anime için DeepSeek AI gerçek anahtar kelime araştırması yaparak CTR-optimize edilmiş SEO başlığı ve meta açıklaması üretir.

    +
    + + +
    + +
    +
    + + {{-- Keyword Araştırması --}} +
    +
    + +
    AI Anahtar Kelime Araştırması
    +
    +
    +
    + + +
    + + +
    +
    + + {{-- Sayfa Analizi --}} +
    +
    + +
    AI Sayfa SEO Analizi
    +
    +
    +
    +
    + +
    +
    + +
    +
    + + +
    +
    + + {{-- FAQ Schema + İçerik Stratejisi --}} +
    +
    +
    +
    + +
    FAQ Schema Üretici
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    90 Günlük İçerik Stratejisi
    +
    +
    +

    Mevcut SEO durumunuza göre 90 günlük detaylı bir SEO eylem planı oluşturun.

    + + +
    +
    +
    +
    + +
    +
    +
    + +
    {{-- /content --}} +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php new file mode 100644 index 0000000..5afd81d --- /dev/null +++ b/resources/views/admin/settings/index.blade.php @@ -0,0 +1,417 @@ +@extends('admin.layouts.app') +@section('title', 'Ayarlar') +@section('page-title', 'Site Ayarları') + +@section('content') +
    +
    + +
    + @csrf + + {{-- Genel --}} +
    +
    + +
    Genel Ayarlar
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + {{-- BunnyCDN --}} +
    +
    + +
    BunnyCDN
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    URL bu kadar dakika sonra geçersiz olur. IDM / direkt link çalışmaz.
    +
    +
    +
    + + {{-- Intro Video --}} +
    +
    + +
    Intro Video
    +
    +
    +
    + Her bölüm başında oynatılacak tanıtım videosu. Intro bittikten sonra asıl video + belirtilen saniyeden başlar. +
    + + @if($settings['intro_video_url']->value ?? null) +
    +
    Mevcut intro:
    + {{ $settings['intro_video_url']->value }} + +
    + @endif + +
    + + +
    Aşağıdan BunnyCDN'e yükledikten sonra burası otomatik dolar.
    +
    + +
    +
    +
    Intro Aktif
    +
    Her bölüm öncesi intro oynat
    +
    +
    + value ?? '0') == '1' ? 'checked' : '' }}> +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    0 = kapalı
    +
    +
    +
    +
    + + {{-- DeepSeek AI --}} +
    +
    + +
    DeepSeek AI
    +
    +
    +
    + + +
    +
    +
    +
    +
    Otomatik Açıklama
    +
    Bot bölüm eklerken boş açıklamayı AI ile doldurur
    +
    +
    + value ?? '0') == '1' ? 'checked' : '' }}> +
    +
    +
    +
    +
    Otomatik SEO
    +
    Bot yeni anime eklerken SEO alanlarını AI ile doldurur
    +
    +
    + value ?? '0') == '1' ? 'checked' : '' }}> +
    +
    +
    +
    +
    + + {{-- Premium Ücretsiz Mod --}} +
    +
    + +
    Premium — Ücretsiz Mod
    +
    +
    +
    + Açıkken tüm kullanıcılar premium özelliklere ücretsiz erişir. +
    +
    +
    +
    Herkes Premium Kullansın
    +
    Kapatınca sadece aboneler erişebilir
    +
    +
    + value ?? '0') == '1' ? 'checked' : '' }}> +
    +
    +
    +
    + + {{-- GIF --}} +
    +
    + +
    GIF Arama
    +
    +
    +
    + + +
    + Ücretsiz: developers.giphy.com → Create App → API +
    +
    +
    + + +
    +
    +
    + + {{-- Yorumlar --}} +
    +
    + +
    Yorum Ayarları
    +
    +
    + @foreach([ + ['comments_enabled','Yorumlar Açık','Kullanıcılar yorum yapabilir','ce'], + ['comments_require_approval','Onay Beklesin','Yorumlar yayınlanmadan önce onaylanır','cra'], + ['nav_show_messages','Navbarda Mesajlar Linki','Kapatınca AI linki gösterilir','nsm'], + ] as [$key,$label,$desc,$id]) +
    +
    +
    {{ $label }}
    +
    {{ $desc }}
    +
    +
    + value ?? '1') == '1' ? 'checked' : '' }}> +
    +
    + @endforeach +
    +
    + + {{-- Reklam (VAST) --}} +
    +
    + +
    Reklam (VAST)
    +
    +
    +
    +
    + value ?? '0') == '1' ? 'checked' : '' }}> + +
    +
    +
    + + +
    Reklam ağından aldığın VAST URL'si buraya yapıştır.
    +
    +
    +
    + +
    + + bölüm +
    +
    Her kullanıcı en fazla bu kadar bölümde bir reklam görür.
    +
    +
    + +
    + + dakika +
    +
    Son reklamdan bu kadar dakika geçmeden tekrar göstermez.
    +
    +
    +
    +
    + + +
    + +{{-- Favicon --}} +
    +
    + +
    Site Favicon
    +
    +
    +
    PNG, ICO veya SVG. Tarayıcı sekmesinde gösterilir. Maksimum 2 MB.
    + + @php $curFav = $settings['site_favicon']->value ?? '' @endphp + @if($curFav) +
    + favicon + {{ $curFav }} +
    + @endif + +
    + @csrf +
    + + +
    + @error('favicon_file')
    {{ $message }}
    @enderror +
    +
    +
    + +{{-- Intro Upload --}} +
    +
    + +
    Intro Video Yükle (BunnyCDN)
    +
    +
    +
    + MP4 veya WebM, maksimum 200 MB. Yükleme tamamlanınca URL yukarıdaki alana otomatik kaydedilir. +
    + +
    + @csrf +
    + + +
    + +
    BunnyCDN'de intro/site-intro.mp4 yoluna kaydedilir.
    +
    +
    +
    + +{{-- SMTP / E-posta --}} +
    +
    + +
    SMTP / E-posta Ayarları
    +
    +
    + + @if(session('mail_success')) +
    {{ session('mail_success') }}
    + @endif + @if(session('mail_error')) +
    {{ session('mail_error') }}
    + @endif + +
    + @csrf @method('POST') +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + +
    + @csrf + +
    + + +
    +
    Ayarları kaydettikten sonra test göndererek SMTP bağlantısını doğrulayın.
    +
    + +
    +
    + +
    +
    +@endsection diff --git a/resources/views/admin/stats/index.blade.php b/resources/views/admin/stats/index.blade.php new file mode 100644 index 0000000..58461a7 --- /dev/null +++ b/resources/views/admin/stats/index.blade.php @@ -0,0 +1,471 @@ +@extends('admin.layouts.app') +@section('title', 'İçerik İstatistikleri') +@section('page-title', 'İçerik İstatistikleri') + +@push('styles') + +@endpush + +@section('content') + +{{-- Özet kartlar --}} +
    +
    +
    +
    +
    +
    {{ number_format($totalAnimes) }}
    +
    Toplam Anime
    +
    {{ number_format($publishedAnimes) }} yayında
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($totalEpisodes) }}
    +
    Toplam Bölüm
    +
    {{ number_format($publishedEps) }} yayında
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    {{ number_format($totalSeasons) }}
    +
    Toplam Sezon
    +
    + +
    +
    +
    +
    +
    +
    +
    + @php $avgEpPerAnime = $totalAnimes > 0 ? round($totalEpisodes / $totalAnimes, 1) : 0; @endphp +
    {{ $avgEpPerAnime }}
    +
    Ort. Bölüm/Anime
    +
    + +
    +
    +
    +
    +
    +
    +
    + @php $unpublished = $totalEpisodes - $publishedEps; @endphp +
    {{ number_format($unpublished) }}
    +
    Taslak Bölüm
    +
    Yayında değil
    +
    + +
    +
    +
    +
    + +{{-- Yükleme Isı Haritası --}} +
    +
    +
    Bölüm Yükleme Yoğunluk Haritası (son 52 hafta)
    +
    + + @php + // Ay etiketleri için hesaplama + $heatMonths = []; + foreach ($heatData as $wi => $week) { + $firstDay = $week[0]['date']; + $month = \Carbon\Carbon::parse($firstDay)->format('M'); + $dayOfMonth = \Carbon\Carbon::parse($firstDay)->day; + if ($dayOfMonth <= 7 && !isset($lastMonth) || (isset($lastMonth) && $lastMonth !== $month)) { + $heatMonths[$wi] = $month; + $lastMonth = $month; + } else { + $heatMonths[$wi] = ''; + } + } + $heatMax = collect($heatData)->flatten(1)->max('cnt') ?: 1; + @endphp + +
    + {{-- Gün etiketleri --}} +
    +   + Pzt +   + Çar +   + Cum +   +
    +
    + {{-- Ay etiketleri --}} +
    + @foreach($heatMonths as $wi => $mon) + {{ $mon }} + @endforeach +
    + {{-- Hücreler --}} +
    +
    + @foreach($heatData as $week) +
    + @foreach($week as $cell) + @php + $cnt = $cell['cnt']; + if ($cnt === 0) $level = 0; + elseif ($cnt <= 2) $level = 1; + elseif ($cnt <= 5) $level = 2; + elseif ($cnt <= 10) $level = 3; + else $level = 4; + $tip = $cell['date'] . ': ' . $cnt . ' bölüm'; + @endphp +
    + @endforeach +
    + @endforeach +
    +
    +
    + Az + + + + + + Çok +
    +
    +
    +
    + +{{-- 90 gün trend + Aylık dağılım --}} +
    +
    +
    +
    Son 90 Gün — Günlük Yükleme Trendi
    +
    + +
    +
    +
    +
    +
    +
    Aylık Bölüm Dağılımı (24 Ay)
    +
    + +
    +
    +
    +
    + +{{-- Saatlik + Haftalık dağılım --}} +
    +
    +
    +
    Saatlik Yükleme Dağılımı (tüm zamanlar)
    +
    + +
    + @php + $peakHour = array_search(max($hourlyEpsData), $hourlyEpsData); + $peakCount = max($hourlyEpsData); + @endphp + @if($peakCount > 0) +
    + En yoğun saat: {{ str_pad($peakHour,2,'0',STR_PAD_LEFT) }}:00 + ({{ number_format($peakCount) }} bölüm) +
    + @endif +
    +
    +
    +
    +
    Haftanın Günlerine Göre (tüm zamanlar)
    +
    + +
    + @php + $peakDow = array_search(max($weekdayData), $weekdayData); + $peakDowCount = max($weekdayData); + @endphp + @if($peakDowCount > 0) +
    + En yoğun gün: {{ $weekdayLabels[$peakDow] }} + ({{ number_format($peakDowCount) }} bölüm) +
    + @endif +
    +
    +
    + +{{-- Top animeler + Tür dağılımı --}} +
    +
    +
    +
    En Fazla Bölüm İçeren 10 Anime
    + @php $maxEpCount = $topByEpisodes->max('episodes_count') ?: 1; @endphp + @foreach($topByEpisodes as $i => $a) +
    + {{ $i+1 }} + + {{ $a->title }} + + @php + $statusColor = match($a->status) { 'ongoing'=>'var(--success)','completed'=>'#79c0ff','upcoming'=>'#f0883e',default=>'var(--text2)' }; + @endphp + + {{ match($a->status){'ongoing'=>'Devam','completed'=>'Bitti','upcoming'=>'Yakında',default=>$a->status} }} + +
    + {{ $a->episodes_count }} +
    + @endforeach +
    +
    +
    +
    +
    Türe Göre Bölüm Sayısı
    + @php $maxGenreEp = $genreEpStats->max('ep_count') ?: 1; @endphp + @foreach($genreEpStats as $g) +
    + {{ $g->name }} +
    + {{ number_format($g->ep_count) }} +
    + @endforeach +
    +
    +
    + +{{-- Son eklenen bölümler --}} +
    +
    +
    +
    Son Eklenen Bölümler
    + @foreach($recentEpisodes as $ep) +
    +
    +
    + {{ $ep->anime?->title ?? '—' }} +
    +
    + S{{ $ep->season?->season_number ?? '?' }}E{{ $ep->episode_number }} + @if($ep->title) · {{ $ep->title }} @endif +
    +
    + + {{ $ep->is_published ? 'Yayında' : 'Taslak' }} + + {{ $ep->created_at->diffForHumans() }} +
    + @endforeach +
    +
    +
    +
    +
    Son Eklenen Animeler
    + @foreach($recentAnimes as $a) +
    +
    + +
    + {{ strtoupper($a->type) }} +
    +
    + + {{ $a->is_published ? 'Yayında' : 'Taslak' }} + + {{ $a->created_at->diffForHumans() }} +
    + @endforeach +
    +
    +
    + +@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/admin/subscriptions/index.blade.php b/resources/views/admin/subscriptions/index.blade.php new file mode 100644 index 0000000..3b68e6f --- /dev/null +++ b/resources/views/admin/subscriptions/index.blade.php @@ -0,0 +1,109 @@ +@extends('admin.layouts.app') +@section('title', 'Abonelikler') +@section('page-title', 'Abonelikler') + +@section('content') + + + +{{-- Filtreler --}} +
    +
    +
    + + +
    + +
    + + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + @forelse($subscriptions as $sub) + + + + + + + + + + @empty + + + + @endforelse + +
    KullanıcıPlanBaşlangıçBitişDurumÖdemeİşlem
    +
    +
    {{ strtoupper(substr($sub->user?->name ?? '?', 0, 1)) }}
    +
    +
    {{ $sub->user?->name ?? '—' }}
    +
    {{ $sub->user?->email ?? '' }}
    +
    +
    +
    {{ $sub->plan?->name ?? '—' }}{{ $sub->starts_at?->format('d.m.Y') ?? '—' }}{{ $sub->expires_at?->format('d.m.Y') ?? '—' }} + @if($sub->status === 'active') + Aktif + @elseif($sub->status === 'expired') + Sona Erdi + @else + İptal + @endif + {{ $sub->payment_method ?? '—' }} + @if($sub->status === 'active') +
    + @csrf @method('DELETE') + +
    + @else + + @endif +
    +
    + +
    Abonelik bulunamadı
    +

    Filtrelerinizi değiştirin.

    +
    +
    +
    +
    + +
    + {{ $subscriptions->links('admin.partials.pagination') }} +
    + +@endsection diff --git a/resources/views/admin/trending/index.blade.php b/resources/views/admin/trending/index.blade.php new file mode 100644 index 0000000..623a402 --- /dev/null +++ b/resources/views/admin/trending/index.blade.php @@ -0,0 +1,274 @@ +@extends('admin.layouts.app') +@section('title','Trend Yönetimi') +@section('page-title','Trend Yönetimi') + +@push('styles') + +@endpush + +@section('content') + + + + + +
    + + {{-- ── Sol: Manuel Trend ── --}} +
    +
    +
    + +
    Manuel Trend Listesi
    + {{ $manual->count() }} anime +
    +
    + @if($manual->isEmpty()) +
    + +
    Manuel trend seçilmedi
    +

    Sağdan anime ekleyin veya otomatik trend kullanılır.

    +
    + @else + @foreach($manual as $anime) +
    +
    {{ $anime->trending_order }}
    + @if($anime->coverUrl) + + @else +
    + @endif +
    +
    {{ $anime->title }}
    +
    {{ $anime->release_year }} · {{ strtoupper($anime->type) }}
    +
    +
    + @if(!$loop->first) +
    + @csrf + + +
    + @endif + @if(!$loop->last) +
    + @csrf + + +
    + @endif +
    + @csrf + +
    +
    +
    + @endforeach + @endif +
    +
    +
    + + {{-- ── Sağ: Otomatik + Ekle ── --}} +
    + + {{-- Arama ve Ekle --}} +
    +
    + +
    Anime Ekle
    +
    +
    +
    + + +
    + +
    +
    + + {{-- Otomatik Trend --}} +
    +
    + +
    Otomatik Trend
    + YouTube-benzeri trending skoru +
    +
    + @foreach($autoTrending as $anime) +
    + @if($anime->coverUrl) + + @else +
    + @endif +
    +
    {{ $anime->title }}
    +
    + @if($anime->trending_score > 0) + + {{ number_format($anime->trending_score, 1) }} puan + @else + {{ number_format($anime->recent_views ?? 0) }} izlenme + @endif +
    +
    + @if($anime->is_trending) + + Trend + + @else +
    + @csrf + +
    + @endif +
    + @endforeach +
    +
    + + {{-- Açıklama --}} +
    +
    +
    + Nasıl Çalışır? +
    +
      +
    • Manuel liste boşsa → otomatik trend (izlenme) gösterilir
    • +
    • Manuel liste doluysa → seçtiğiniz animeler sırayla görünür
    • +
    • Sıralama yukarı/aşağı butonlarıyla değiştirilir
    • +
    +
    +
    + +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php new file mode 100644 index 0000000..b08cb2d --- /dev/null +++ b/resources/views/admin/users/edit.blade.php @@ -0,0 +1,77 @@ +@extends('admin.layouts.app') +@section('title', 'Kullanıcı Düzenle') +@section('page-title', 'Kullanıcı Düzenle') + +@section('content') + + + +
    +
    +
    +
    +
    {{ strtoupper(substr($user->name,0,1)) }}
    +
    {{ $user->name }}
    + — Düzenle +
    +
    +
    + @csrf @method('PUT') + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    Boş bırakırsan rozet gösterilmez.
    +
    + +
    + + İptal +
    +
    +
    +
    +
    +
    + +@endsection diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..f671da1 --- /dev/null +++ b/resources/views/admin/users/index.blade.php @@ -0,0 +1,134 @@ +@extends('admin.layouts.app') +@section('title', 'Kullanıcılar') +@section('page-title', 'Kullanıcılar') + +@section('content') + + + +{{-- ── Filters ── --}} +
    +
    +
    + + +
    + + +
    + + +
    +
    +
    + +{{-- ── Table ── --}} +
    +
    + + + + + + + + + + + + + + @forelse($users as $user) + + + + + + + + + + @empty + + + + @endforelse + +
    KullanıcıRolÜyelikPremium BitişDurumKayıt Tarihiİşlem
    +
    +
    + @if($user->avatar) + + @else + {{ strtoupper(substr($user->name, 0, 1)) }} + @endif +
    +
    +
    {{ $user->name }}
    +
    {{ $user->email }}
    +
    +
    +
    + @if($user->role === 'admin') + Admin + @elseif($user->role === 'moderator') + Moderatör + @else + Kullanıcı + @endif + + @if($user->membership === 'premium') + Premium + @else + Ücretsiz + @endif + + {{ $user->premium_expires_at ? $user->premium_expires_at->format('d.m.Y') : '—' }} + + @if($user->is_banned) + + Banlı + + @else + + Aktif + + @endif + + {{ $user->created_at->format('d.m.Y') }} + + + + Görüntüle + +
    +
    + +
    Kullanıcı bulunamadı
    +

    Arama kriterlerinizi değiştirin.

    +
    +
    +
    +
    + +
    + {{ $users->links('admin.partials.pagination') }} +
    + +@endsection diff --git a/resources/views/admin/users/show.blade.php b/resources/views/admin/users/show.blade.php new file mode 100644 index 0000000..1565e7c --- /dev/null +++ b/resources/views/admin/users/show.blade.php @@ -0,0 +1,267 @@ +@extends('admin.layouts.app') +@section('title', $user->name) +@section('page-title', $user->name) + +@push('styles') + +@endpush + +@section('content') + + + +
    + + {{-- ── Sol Kolon ── --}} +
    + + {{-- Profil Kartı --}} +
    +
    +
    + @if($user->avatar) + + @else + {{ strtoupper(substr($user->name, 0, 1)) }} + @endif +
    +
    {{ $user->name }}
    +
    {{ $user->email }}
    +
    + @if($user->role === 'admin') + Admin + @elseif($user->role === 'moderator') + Moderatör + @else + Kullanıcı + @endif + @if($user->membership === 'premium') + Premium + @else + Ücretsiz + @endif + @if($user->is_banned) + Banlı + @endif +
    +
    +
    + + {{-- Bilgiler --}} +
    +
    + +
    Hesap Bilgileri
    +
    +
    +
    + Kayıt Tarihi + {{ $user->created_at->format('d.m.Y') }} +
    +
    + Son Giriş + {{ $user->last_login_at ? $user->last_login_at->format('d.m.Y H:i') : '—' }} +
    + @if($user->membership === 'premium') +
    + Premium Bitiş + + {{ $user->premium_expires_at ? $user->premium_expires_at->format('d.m.Y') : 'Süresiz' }} + +
    + @endif + @if($user->is_banned) +
    + Ban Tarihi + {{ $user->banned_at?->format('d.m.Y') ?? '—' }} +
    + @if($user->ban_reason) +
    + Ban Sebebi + {{ $user->ban_reason }} +
    + @endif + @endif +
    +
    + + {{-- İşlemler --}} +
    +
    + +
    İşlemler
    +
    +
    + + Düzenle + + + @if($user->is_banned) +
    + @csrf + +
    + @else + + @endif + + @if($user->id !== auth()->id()) +
    + @csrf @method('DELETE') + +
    + @endif +
    +
    + + {{-- Premium Yönetimi --}} +
    +
    + +
    Premium Yönetimi
    +
    +
    + @if($user->membership === 'premium') +
    + @csrf + +
    + @endif +
    + @csrf +
    + +
    + +
    +
    +
    + +
    + + {{-- ── Sağ Kolon ── --}} +
    + + {{-- Abonelik Geçmişi --}} +
    +
    + +
    Abonelik Geçmişi
    +
    +
    + @forelse($user->subscriptions as $sub) +
    +
    +
    {{ $sub->plan->name ?? '—' }}
    +
    + {{ $sub->starts_at?->format('d.m.Y') }} — {{ $sub->expires_at?->format('d.m.Y') }} +
    +
    + + {{ match($sub->status) { 'active'=>'Aktif', 'expired'=>'Sona Erdi', default=>'İptal' } }} + +
    + @empty +
    + +

    Abonelik geçmişi yok.

    +
    + @endforelse +
    +
    + + {{-- Son Yorumlar --}} +
    +
    + +
    Son Yorumlar
    +
    +
    + @forelse($user->comments->take(10) as $comment) +
    +
    + {{ $comment->created_at->format('d.m.Y H:i') }} + + {{ match($comment->status) { 'approved'=>'Onaylı', 'pending'=>'Bekliyor', default=>'Reddedildi' } }} + +
    +
    {{ Str::limit($comment->content, 120) }}
    +
    + @empty +
    + +

    Henüz yorum yapılmamış.

    +
    + @endforelse +
    +
    + +
    +
    + +{{-- Ban Modal --}} + + +@endsection diff --git a/resources/views/emails/layout.blade.php b/resources/views/emails/layout.blade.php new file mode 100644 index 0000000..463339a --- /dev/null +++ b/resources/views/emails/layout.blade.php @@ -0,0 +1,35 @@ + + + + + +@yield('title','Animexe') + + + +
    +
    + +
    +
    + @yield('content') +
    +
    + Bu e-postayı siz talep etmediyseniz dikkate almayınız.
    + © {{ date('Y') }} Animexe — Tüm hakları saklıdır. +
    +
    + + diff --git a/resources/views/emails/reset-password.blade.php b/resources/views/emails/reset-password.blade.php new file mode 100644 index 0000000..84f8d52 --- /dev/null +++ b/resources/views/emails/reset-password.blade.php @@ -0,0 +1,12 @@ +@extends('emails.layout') +@section('content') +
    🔐 Şifre Sıfırlama
    +

    Merhaba {{ $userName }},

    +

    Animexe hesabınız için şifre sıfırlama talebinde bulundunuz. Aşağıdaki butona tıklayarak yeni şifrenizi belirleyebilirsiniz.

    + +
    +

    Bu bağlantı 60 dakika geçerlidir. Şifre sıfırlama talebinde bulunmadıysanız bu e-postayı dikkate almayınız — hesabınız güvende.

    +

    Buton çalışmıyorsa bu bağlantıyı tarayıcınıza yapıştırın:
    {{ $resetUrl }}

    +@endsection diff --git a/resources/views/emails/test.blade.php b/resources/views/emails/test.blade.php new file mode 100644 index 0000000..14364e7 --- /dev/null +++ b/resources/views/emails/test.blade.php @@ -0,0 +1,7 @@ +@extends('emails.layout') +@section('content') +
    ✅ SMTP Bağlantısı Başarılı
    +

    Bu e-posta, Animexe admin panelinden yapılan SMTP test gönderimidir. E-postayı aldıysanız SMTP ayarlarınız doğru çalışıyor demektir.

    +
    +

    Gönderim zamanı: {{ now()->format('d.m.Y H:i:s') }}

    +@endsection diff --git a/resources/views/emails/verify-email.blade.php b/resources/views/emails/verify-email.blade.php new file mode 100644 index 0000000..38c680d --- /dev/null +++ b/resources/views/emails/verify-email.blade.php @@ -0,0 +1,12 @@ +@extends('emails.layout') +@section('content') +
    📧 E-posta Adresini Doğrula
    +

    Merhaba {{ $userName }}, Animexe'ye hoş geldin!

    +

    Hesabını aktifleştirmek için e-posta adresini doğrulamanı istiyoruz. Aşağıdaki butona tıkla:

    + +
    +

    Bu bağlantı 24 saat geçerlidir. Hesap açmadıysanız bu e-postayı dikkate almayınız.

    +

    Buton çalışmıyorsa bu bağlantıyı tarayıcınıza yapıştırın:
    {{ $verifyUrl }}

    +@endsection diff --git a/resources/views/frontend/ai/index.blade.php b/resources/views/frontend/ai/index.blade.php new file mode 100644 index 0000000..4b7602b --- /dev/null +++ b/resources/views/frontend/ai/index.blade.php @@ -0,0 +1,462 @@ +@extends('frontend.layouts.app') +@section('title','AI Asistan — Animexe') +@push('styles') + +@endpush + +@section('content') +
    +
    +

    Animexe AI Asistan

    +

    Yapay zeka destekli anime keşfi. Ruh haline göre öneri al, doğal dille ara veya sağ alttaki balondan sohbet et.

    + + @guest +
    + +

    AI özelliklerini kullanmak için ücretsiz hesap oluştur veya giriş yap.

    + Ücretsiz Kayıt Ol + Giriş Yap +
    + @endguest + +
    + + + +
    +
    + +
    + + {{-- ── TAB 1: Kişisel Öneri ─────────────────────────────────── --}} +
    +
    +
    Nasıl bir ruh halindeydin?
    +
    + @foreach([ + ['😤','Aksiyona dolu','action'],['😢','Duygusal ağlatıcı','emotional'], + ['😂','Komik ve eğlenceli','comedy'],['😨','Gerilim/Korku','thriller'], + ['🌸','Romantik','romance'],['🤯','Akıl oyunları','mindgame'], + ['⚔️','Fantezi macera','fantasy'],['🤖','Sci-Fi / Mecha','scifi'], + ['🏫','Okul / Slice of life','school'],['💪','Motivasyonel','motivational'], + ] as $m) + + @endforeach +
    + +
    Tür tercihleri (opsiyonel)
    +
    + @foreach($genres as $g) + + @endforeach +
    + +
    İçerik tipi
    +
    + + + +
    + + +
    +
    + +
    +
    + AI düşünüyor... +
    + +
    +
    + AI Önerileri +
    +
    +
    +
    + + {{-- ── TAB 3: Platform Yardımı ─────────────────────────────── --}} + + + {{-- ── TAB 2: Doğal Dil Arama ─────────────────────────────── --}} + + +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/frontend/anime-request.blade.php b/resources/views/frontend/anime-request.blade.php new file mode 100644 index 0000000..7bc3bcc --- /dev/null +++ b/resources/views/frontend/anime-request.blade.php @@ -0,0 +1,213 @@ +@extends('frontend.layouts.app') +@section('title', 'Anime İsteği — Animexe') + +@push('styles') + +@endpush + +@section('content') +
    + +
    +

    Anime İsteği

    +

    İzlemek istediğin ama sitede olmayan bir anime var mı? Buradan bildir, en çok istenen animeleri ekliyoruz.

    +
    + +
    + + {{-- İstek formu (sadece üyeler) --}} + @auth +
    +

    Yeni İstek Gönder

    + +
    + + +
    +
    + + +
    +
    + + +
    + +
    + @else +
    + +

    Anime isteği göndermek için giriş yapman gerekiyor.

    + Giriş Yap + Kayıt Ol +
    + @endauth + + {{-- İstekler listesi --}} +
    +
    +

    Tüm İstekler

    + {{ $requests->total() }} istek +
    + + @forelse($requests as $req) + @php + $st = \App\Models\AnimeRequest::STATUSES[$req->status] ?? ['label'=>$req->status,'color'=>'#8b949e']; + $hasVoted = in_array($req->id, $votedIds); + @endphp +
    +
    + @auth + + @else + + @endauth + {{ $req->vote_count }} +
    +
    +
    {{ $req->title }}
    + @if($req->original_title)
    {{ $req->original_title }}
    @endif + @if($req->note)
    {{ Str::limit($req->note, 150) }}
    @endif +
    + {{ $st['label'] }} + @if($req->user){{ $req->user->name }} tarafından istendi@endif + · {{ $req->created_at->diffForHumans() }} +
    + @if($req->admin_note) +
    {{ $req->admin_note }}
    + @endif +
    +
    + @empty +
    Henüz istek yok. İlk isteği sen gönder!
    + @endforelse +
    + +
    {{ $requests->links() }}
    +
    + +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/frontend/anime.blade.php b/resources/views/frontend/anime.blade.php new file mode 100644 index 0000000..2da048e --- /dev/null +++ b/resources/views/frontend/anime.blade.php @@ -0,0 +1,1337 @@ +@extends('frontend.layouts.app') +@php + $__izleLabel = $anime->type === 'movie' ? 'Filmi İzle' : 'İzle'; + $__animeSeoTitle = $anime->title . ' ' . $__izleLabel . ' - Türkçe Altyazılı | Animexe'; + $__animeSeoDesc = $anime->title . ' ' . $__izleLabel + . ' — Türkçe altyazılı ve dublajlı, ücretsiz HD kalitede Animexe\'de.' + . ($anime->description ? ' ' . Str::limit(strip_tags($anime->description), 80) : ''); + $__animeOgImg = $anime->bannerUrl ?? $anime->coverUrl ?? url('/logo.jpg'); + $__animeCanon = rtrim(\App\Models\Setting::get('seo_canonical_domain', config('app.url')), '/') . route('anime.show', $anime->slug, false); + $__animeType = $anime->type === 'movie' ? 'Movie' : 'TVSeries'; + $__animeGenres = $anime->genres->pluck('name')->implode(', '); + $__domain = rtrim(\App\Models\Setting::get('seo_canonical_domain', config('app.url')), '/'); + + // Auto-FAQ: DB verilerinden kurallı sorular + $__faqItems = []; + $__faqItems[] = [ + 'q' => $anime->title . ' nerede izlenir?', + 'a' => $anime->title . ', Animexe\'de Türkçe altyazılı ve Türkçe dublajlı olarak ücretsiz HD kalitede izlenebilir.', + ]; + $epCount = $anime->episode_count ?: $anime->episodes->count(); + if ($epCount > 0) { + $__faqItems[] = [ + 'q' => $anime->title . ' kaç bölüm?', + 'a' => $anime->type === 'movie' + ? $anime->title . ' tek bölümlük bir anime filmidir.' + : $anime->title . ' şu an Animexe\'de ' . $epCount . ' bölümüyle yayında.', + ]; + } + if ($anime->release_year) { + $__faqItems[] = [ + 'q' => $anime->title . ' ne zaman çıktı?', + 'a' => $anime->title . ', ' . $anime->release_year . ' yılında yayımlanmaya başladı.', + ]; + } + if ($anime->genres->count()) { + $__faqItems[] = [ + 'q' => $anime->title . ' hangi türde?', + 'a' => $anime->title . ', ' . $__animeGenres . ' türlerinde bir anime' . ($anime->type === 'movie' ? ' filmidir.' : ' dizisidir.'), + ]; + } + if ($anime->rating) { + $__faqItems[] = [ + 'q' => $anime->title . ' puanı nedir?', + 'a' => $anime->title . '\' un Animexe kullanıcı puanı 10 üzerinden ' . number_format($anime->rating,1) . '\'dir.', + ]; + } + $statusText = match($anime->status ?? '') { + 'ongoing' => $anime->title . ' devam eden bir anime olup yeni bölümleri Animexe\'de yayımlanmaktadır.', + 'completed' => $anime->title . ' tamamlanmış bir anime olup tüm bölümleri Animexe\'de mevcuttur.', + default => null, + }; + if ($statusText) { + $__faqItems[] = ['q' => $anime->title . ' bitti mi, devam ediyor mu?', 'a' => $statusText]; + } + if ($anime->studio) { + $__faqItems[] = [ + 'q' => $anime->title . '\'u hangi stüdyo yaptı?', + 'a' => $anime->title . ', ' . $anime->studio . ' stüdyosu tarafından üretilmiştir.', + ]; + } +@endphp +@section('title', $__animeSeoTitle) +@section('meta_description', $__animeSeoDesc) +@section('meta_keywords', $anime->title . ' izle, ' . $anime->title . ' türkçe izle, ' . ($anime->title_en ? $anime->title_en . ' izle, ' : '') . 'anime izle, türkçe anime, ' . $__animeGenres . ' anime izle') +@section('og_type', $anime->type === 'movie' ? 'video.movie' : 'video.tv_show') +@section('og_title', $__animeSeoTitle) +@section('og_description', $__animeSeoDesc) +@section('og_image', $__animeOgImg) +@section('og_image_width', '1280') +@section('og_image_height', '720') +@section('canonical', $__animeCanon) +{{-- LCP: Cover görseli preload --}} +@section('preload') +@if($anime->bannerUrl ?? $anime->coverUrl) + +@endif +@endsection +{{-- JSON-LD schemas --}} +@section('schema') +@if((\App\Models\Setting::get('seo_enable_schema','1')) === '1') + +{{-- 1. TVSeries / Movie --}} + + +{{-- 2. VideoObject --}} + + +{{-- 3. FAQPage (auto-generated) --}} +@if(count($__faqItems) > 0) + +@endif + +{{-- 4. Episode ItemList --}} +@php $__epList = $anime->episodes->take(20); @endphp +@if($__epList->count() > 0 && $anime->type !== 'movie') + +@endif + +{{-- 5. BreadcrumbList --}} +@if((\App\Models\Setting::get('seo_enable_breadcrumb','1')) === '1') + +@endif + +@endif +@endsection +@push('styles') + +@endpush +@section('content') + +{{-- Hero --}} +
    + @if($anime->bannerUrl ?: $anime->coverUrl) +
    + @else +
    + @endif +
    +
    + +
    + {{-- Left: info --}} +
    +
    + {{ $anime->type==='movie' ? 'Film' : 'Dizi' }} + @if($anime->status==='ongoing')Devam Ediyor + @elseif($anime->status==='completed')Tamamlandı + @elseYakında@endif + @if($anime->release_year){{ $anime->release_year }}@endif +
    + +

    {{ $anime->title }} {{ $__izleLabel }}

    + + @if($anime->title_en || $anime->rating) +
    + @if($anime->title_en){{ $anime->title_en }}@endif + @if($anime->title_en && $anime->title_jp){{ $anime->title_jp }}@endif + @if($anime->rating) + @if($anime->title_en)@endif + {{ number_format($anime->rating,1) }} + @endif +
    + @endif + + @if($anime->genres->count()) +
    + @foreach($anime->genres->take(5) as $g) + {{ $g->name }} + @endforeach +
    + @endif + + {{-- Action buttons --}} +
    + @if($anime->seasons->count() && $anime->seasons->first()->episodes->count()) + @php $s1=$anime->seasons->first(); $e1=$s1->episodes->first(); @endphp + @auth + @if($continueEp && $continueEp->percent_complete > 3) + + + Devam Et — S{{ str_pad($continueEp->season_number,2,'0',STR_PAD_LEFT) }}E{{ str_pad($continueEp->episode_number,2,'0',STR_PAD_LEFT) }} + %{{ $continueEp->percent_complete }} + + @else + + İzlemeye Başla + + @endif + @else + + İzlemeye Başla + + @endauth + @endif + + {{-- Watchlist --}} + @auth +
    + + +
    + @else + + Listeye Ekle + + @endauth + + {{-- Follow --}} + @auth + + @else + + Takip Et + + @endauth + + {{-- Anime Mahkemesi --}} + + Mahkeme + + + {{-- Zaman Kapsülü --}} + @auth + + @endauth + + {{-- Paylaş --}} + +
    + + {{-- Sosyal: Kimler izliyor --}} + @if($watcherCount > 0) +
    +
    + @foreach($watchers->take(6) as $w) +
    + @if($w->avatar)@else{{ mb_strtoupper(mb_substr($w->name,0,1)) }}@endif +
    + @endforeach +
    + + @if($watcherCount === 1) 1 kişi listesine ekledi + @else {{ number_format($watcherCount) }} kişi listesine ekledi + @endif + +
    + @endif + + {{-- User rating --}} + @auth +
    + Puanın: + @for($i=1;$i<=10;$i++) + + @endfor + @if($userRating){{ $userRating }}/10@endif +
    + @else + + @endauth +
    + + {{-- Right: Poster --}} +
    + @if($anime->coverUrl) + {{ $anime->title }} + @else +
    + @endif + @if($anime->rating) +
    {{ number_format($anime->rating,1) }}
    + @endif +
    +
    +
    + +{{-- Body --}} +
    + + {{-- Stats strip --}} + @php $totalEps = $anime->seasons->sum(fn($s) => $s->episodes->count()); @endphp +
    + @if($anime->rating) +
    +
    +
    +
    {{ number_format($anime->rating,1) }}
    +
    Puan
    +
    +
    + @endif +
    +
    +
    +
    {{ $totalEps }}
    +
    Bölüm
    +
    +
    +
    +
    +
    +
    {{ $anime->seasons->count() }}
    +
    Sezon
    +
    +
    + @if($anime->release_year) +
    +
    +
    +
    {{ $anime->release_year }}
    +
    Yıl
    +
    +
    + @endif + @if($anime->studio) +
    +
    +
    +
    {{ $anime->studio }}
    +
    Stüdyo
    +
    +
    + @endif +
    + + {{-- Description --}} + @if($anime->description) +
    +

    {{ $anime->description }}

    + +
    + @endif + + {{-- Tabs --}} +
    + + + @if($related->count())@endif + @auth + + @else + + @endauth +
    + + {{-- Episodes --}} +
    + @if($anime->seasons->count() > 1) +
    + @foreach($anime->seasons as $s) + + @endforeach +
    + @endif + + @foreach($anime->seasons as $s) + + @endforeach +
    + + {{-- Info --}} +
    + + @if($anime->studio)@endif + @if($anime->release_year)@endif + + + @if($anime->episode_count)@endif + @if($anime->rating)@endif + @if($anime->genres->count())@endif + @if($anime->mal_id)@endif +
    Stüdyo{{ $anime->studio }}
    Yıl{{ $anime->release_year }}
    Tür{{ $anime->type==='movie' ? 'Film' : 'Dizi' }}
    Durum{{ $anime->status==='ongoing' ? 'Devam Ediyor' : ($anime->status==='completed' ? 'Tamamlandı' : 'Yakında') }}
    Toplam Bölüm{{ $anime->episode_count }}
    Puan★ {{ number_format($anime->rating,1) }} / 10
    Türler
    @foreach($anime->genres as $g){{ $g->name }}@endforeach
    MAL ID{{ $anime->mal_id }}
    +
    + + {{-- Related --}} + @if($related->count()) + + @endif + + {{-- AI Similar --}} + @auth +
    +
    +
    +
    +
    +
    AI Benzer Öneri
    +
    Bu animeye benzer alternatifleri yapay zeka analiz ediyor
    +
    +
    +
    +
    +
    +
    +
    +
    + AI analiz yapıyor... +
    + + +
    +
    + @endauth + +
    + +{{-- FAQ Section (SEO + UX) --}} +@if(count($__faqItems) > 0) +
    +
    +

    + {{ $anime->title }} Hakkında Sıkça Sorulan Sorular +

    +
    + @foreach($__faqItems as $fq) +
    + + {{ $fq['q'] }} + + +
    +

    {{ $fq['a'] }}

    +
    +
    + @endforeach +
    +
    +
    +@endif + +@endsection +{{-- Login prompt modal (misafirler için) --}} + + +@push('scripts') + +@endpush + +{{-- Zaman Kapsülü Modal --}} +@auth + +@endauth diff --git a/resources/views/frontend/auth/forgot-password.blade.php b/resources/views/frontend/auth/forgot-password.blade.php new file mode 100644 index 0000000..f7a585e --- /dev/null +++ b/resources/views/frontend/auth/forgot-password.blade.php @@ -0,0 +1,57 @@ +@extends('frontend.layouts.app') +@section('title','Şifremi Unuttum — Animexe') +@push('styles') + +@endpush +@section('content') +
    +
    + + + @if(session('status')) +
    {{ session('status') }}
    + @endif + + @if($errors->any()) +
    {{ $errors->first() }}
    + @endif + +

    Kayıtlı e-posta adresini gir, sana şifre sıfırlama bağlantısı gönderelim.

    + +
    + @csrf +
    + + +
    + +
    + + +
    +
    +@endsection diff --git a/resources/views/frontend/auth/login.blade.php b/resources/views/frontend/auth/login.blade.php new file mode 100644 index 0000000..f567e71 --- /dev/null +++ b/resources/views/frontend/auth/login.blade.php @@ -0,0 +1,159 @@ +@extends('frontend.layouts.app') +@section('title','Giriş Yap — Animexe') +@push('styles') + +@endpush +@section('content') +
    +
    + + + @if($errors->any()) +
    {{ $errors->first() }}
    + @endif + +
    + @csrf +
    + + +
    +
    + + +
    +
    +
    + + +
    + Şifremi unuttum +
    + +
    + +
    veya şununla devam et
    + + + +
    + Hesabın yok mu? Üye ol +
    +
    +
    +@endsection diff --git a/resources/views/frontend/auth/register.blade.php b/resources/views/frontend/auth/register.blade.php new file mode 100644 index 0000000..09f0b3c --- /dev/null +++ b/resources/views/frontend/auth/register.blade.php @@ -0,0 +1,110 @@ +@extends('frontend.layouts.app') +@section('title','Üye Ol — Animexe') +@push('styles') + +@endpush +@section('content') +
    +
    + + + @if($errors->any()) +
    {{ $errors->first() }}
    + @endif + +
    + @csrf +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +

    Üye olarak Kullanım Koşulları'nı kabul etmiş olursunuz.

    +
    + +
    veya şununla kayıt ol
    + + + +
    + Zaten üye misin? Giriş yap +
    +
    +
    +@endsection diff --git a/resources/views/frontend/auth/reset-password.blade.php b/resources/views/frontend/auth/reset-password.blade.php new file mode 100644 index 0000000..cf4a412 --- /dev/null +++ b/resources/views/frontend/auth/reset-password.blade.php @@ -0,0 +1,53 @@ +@extends('frontend.layouts.app') +@section('title','Yeni Şifre Belirle — Animexe') +@push('styles') + +@endpush +@section('content') +
    +
    + + + @if($errors->any()) +
    {{ $errors->first() }}
    + @endif + +
    + @csrf + +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +@endsection diff --git a/resources/views/frontend/auth/verify-email.blade.php b/resources/views/frontend/auth/verify-email.blade.php new file mode 100644 index 0000000..b31c350 --- /dev/null +++ b/resources/views/frontend/auth/verify-email.blade.php @@ -0,0 +1,48 @@ +@extends('frontend.layouts.app') +@section('title','E-posta Doğrulama — Animexe') +@push('styles') + +@endpush +@section('content') +
    +
    +
    📧
    +
    E-postanı Doğrula
    +

    + {{ auth()->user()->email }} adresine doğrulama bağlantısı gönderdik.
    + Gelen kutunu kontrol et. Spam klasörüne de bakmanı öneririz. +

    + + @if(session('status')) +
    {{ session('status') }}
    + @endif + +
    + @csrf + +
    + + +
    +
    +@endsection diff --git a/resources/views/frontend/blog/index.blade.php b/resources/views/frontend/blog/index.blade.php new file mode 100644 index 0000000..2e68ded --- /dev/null +++ b/resources/views/frontend/blog/index.blade.php @@ -0,0 +1,144 @@ +@extends('frontend.layouts.app') +@section('title', 'Anime Blog — Rehberler, İncelemeler ve Öneriler | Animexe') +@section('meta_description', 'Animexe anime blogunda izleme rehberleri, anime incelemeleri, tür önerileri ve AI destekli içerikler keşfedin.') +@section('meta_keywords', 'anime blog, anime rehberi, anime inceleme, anime önerisi, animexe blog') +@section('og_type', 'website') +@push('styles') + +@endpush + +@section('content') +
    +
    +

    Anime Blog

    +

    AI destekli anime rehberleri, izleme önerileri ve derinlemesine incelemeler.

    +
    +
    + +
    +
    +
    + @if($posts->count()) + + + @if($posts->hasPages()) +
    {{ $posts->links('frontend.pagination') }}
    + @endif + @else +
    + +

    Henüz blog yazısı yok. Yakında yapay zeka destekli içerikler gelecek!

    +
    + @endif +
    + + +
    +
    +@endsection diff --git a/resources/views/frontend/blog/show.blade.php b/resources/views/frontend/blog/show.blade.php new file mode 100644 index 0000000..1fb95ae --- /dev/null +++ b/resources/views/frontend/blog/show.blade.php @@ -0,0 +1,234 @@ +@extends('frontend.layouts.app') +@php + $__canon = rtrim(\App\Models\Setting::get('seo_canonical_domain', config('app.url')), '/') . route('blog.show', $post->slug, false); + $__ogImg = $post->coverUrl; + $__pubDate = $post->published_at?->toIso8601String(); + $__modDate = $post->updated_at?->toIso8601String(); + $__faqItems = $post->faq ?? []; +@endphp +@section('title', ($post->meta_title ?: $post->title) . ' — Animexe Blog') +@section('meta_description', $post->meta_description ?: $post->excerpt) +@section('meta_keywords', $post->meta_keywords) +@section('og_type', 'article') +@section('og_title', $post->title) +@section('og_description', $post->excerpt) +@if($__ogImg)@section('og_image', $__ogImg)@endif +@section('canonical', $__canon) +@section('robots', 'index, follow') + +@section('schema') + +@if(count($__faqItems) > 0) + +@endif + +@endsection + +@push('styles') + +@endpush + +@section('content') +
    +
    +
    + + @if($post->coverUrl) + {{ $post->title }} + @endif + + @if($post->ai_generated) +
    AI Destekli İçerik
    + @endif + +

    {{ $post->title }}

    + +
    + @if($post->published_at) + + + + + @endif + {{ $post->reading_time }} dk okuma + @if($post->anime) + + + {{ $post->anime->title }} + + @endif + @if($post->focus_keyword) + {{ $post->focus_keyword }} + @endif +
    + +
    + {!! $post->content !!} +
    + + {{-- FAQ Bölümü --}} + @if(count($__faqItems) > 0) +
    +

    Sıkça Sorulan Sorular

    + @foreach($__faqItems as $faq) +
    +

    {{ $faq['q'] ?? '' }}

    +

    {{ $faq['a'] ?? '' }}

    +
    + @endforeach +
    + @endif + + {{-- İlgili Anime Linkleri --}} + @if($linkedAnimes->count()) + + @endif + + {{-- CTA --}} + @if($post->anime) +
    +

    {{ $post->anime->title }} İzle

    +

    + Animexe'de Türkçe altyazılı veya dublajlı ücretsiz izle. +

    + + Hemen İzle + +
    + @endif +
    + + {{-- Sidebar --}} + +
    +
    +@endsection diff --git a/resources/views/frontend/capsules.blade.php b/resources/views/frontend/capsules.blade.php new file mode 100644 index 0000000..bb28e39 --- /dev/null +++ b/resources/views/frontend/capsules.blade.php @@ -0,0 +1,114 @@ +@extends('frontend.layouts.app') +@section('title', 'Zaman Kapsüllerim | Animexe') +@push('styles') + +@endpush +@section('content') +
    + +
    +

    ⏳ Zaman Kapsüllerim

    +

    Geçmiş benliğinden gelen mesajlar.

    +
    + + @if($capsules->isEmpty()) +
    + + Henüz kapsül oluşturmadın.
    + Bir anime sayfasından ilk kez izlerken kapsül bırakabilirsin. +
    + @else +
    + @foreach($capsules as $cap) +
    + {{ $cap['opened'] ? '📭' : ($cap['unlocked'] ? '🔓' : '🔒') }} + + {{ $cap['opened'] ? 'Açıldı' : ($cap['unlocked'] ? 'Açılabilir' : 'Kilitli') }} + + +
    + + Oluşturuldu: {{ $cap['created_at'] }} · Açılış: {{ $cap['unlock_at'] }} +
    + + @if($cap['opened'] || $cap['unlocked']) + @if($cap['message']) +
    {{ $cap['message'] }}
    + @else + + @endif + @else +
    +
    + Bu kapsül {{ $cap['unlock_at'] }} tarihinde açılacak +
    + @endif +
    + @endforeach +
    + @endif + +
    +@endsection +@push('scripts') + +@endpush diff --git a/resources/views/frontend/checkout/failed.blade.php b/resources/views/frontend/checkout/failed.blade.php new file mode 100644 index 0000000..ecd9376 --- /dev/null +++ b/resources/views/frontend/checkout/failed.blade.php @@ -0,0 +1,35 @@ +@extends('frontend.layouts.app') +@section('title', 'Ödeme Başarısız | Animexe') + +@push('styles') + +@endpush + +@section('content') +
    +
    +

    Ödeme Başarısız

    +

    + Ödeme işlemi tamamlanamadı. Kart bilgilerini kontrol edip tekrar deneyebilirsin + veya farklı bir kart kullanabilirsin. +

    + +
    +@endsection diff --git a/resources/views/frontend/checkout/form.blade.php b/resources/views/frontend/checkout/form.blade.php new file mode 100644 index 0000000..2c024d4 --- /dev/null +++ b/resources/views/frontend/checkout/form.blade.php @@ -0,0 +1,31 @@ +@extends('frontend.layouts.app') +@section('title', 'Ödeme | Animexe') + +@push('styles') + +@endpush + +@section('content') +
    +
    +

    Kart Bilgileri

    +

    {{ $plan->name }} — ₺{{ number_format($plan->price, 0) }} / {{ $plan->duration_days }} gün

    +
    + +
    + {!! $formContent !!} +
    + +
    + + Kart bilgileriniz iyzico'nun güvenli sunucularında işlenir +
    +
    +@endsection diff --git a/resources/views/frontend/checkout/show.blade.php b/resources/views/frontend/checkout/show.blade.php new file mode 100644 index 0000000..8681655 --- /dev/null +++ b/resources/views/frontend/checkout/show.blade.php @@ -0,0 +1,104 @@ +@extends('frontend.layouts.app') +@section('title', $plan->name . ' — Ödeme | Animexe') + +@push('styles') + +@endpush + +@section('content') +
    +
    + Planlara dön +
    +
    {{ $plan->name }}
    +
    {{ number_format($plan->price, 0) }}
    +
    {{ $plan->duration_days }} günlük premium üyelik
    + @if(($plan->trial_days ?? 0) > 0) +
    + İlk {{ $plan->trial_days }} gün ücretsiz +
    + @endif + @if(!empty($plan->features)) +
      + @foreach($plan->features as $feat) +
    • {{ $feat }}
    • + @endforeach +
    + @endif +
    +
    + +
    +

    Fatura Bilgileri

    + + @if($errors->has('general')) +
    {{ $errors->first('general') }}
    + @endif + +
    + @csrf +
    + + + @error('full_name'){{ $message }}@enderror +
    +
    + + +
    +
    + + + @error('phone'){{ $message }}@enderror +
    +
    + + + @error('city'){{ $message }}@enderror +
    +
    + + + @error('address'){{ $message }}@enderror +
    +
    + + + iyzico güvenlik doğrulaması için istenir + @error('identity_no'){{ $message }}@enderror +
    + +
    +
    + + iyzico altyapısıyla güvenli ödeme +
    +
    +
    +@endsection diff --git a/resources/views/frontend/checkout/success.blade.php b/resources/views/frontend/checkout/success.blade.php new file mode 100644 index 0000000..b56e01c --- /dev/null +++ b/resources/views/frontend/checkout/success.blade.php @@ -0,0 +1,29 @@ +@extends('frontend.layouts.app') +@section('title', 'Ödeme Başarılı | Animexe') + +@push('styles') + +@endpush + +@section('content') +
    +
    +

    Premium Aktif! 🎉

    +

    + @if(session('checkout_plan_name')) + {{ session('checkout_plan_name') }} planın aktif edildi.
    + @endif + Artık tüm premium özelliklerden yararlanabilirsin. +

    + + Kozmetiklerini Ayarla + +
    +@endsection diff --git a/resources/views/frontend/discover.blade.php b/resources/views/frontend/discover.blade.php new file mode 100644 index 0000000..16f1bda --- /dev/null +++ b/resources/views/frontend/discover.blade.php @@ -0,0 +1,2018 @@ +@extends('frontend.layouts.app') +@section('title', 'Keşfet — Sana Uygun Animeyi Bul') +@section('meta_description', 'Kaydır, beğen, listene ekle. Binlerce anime arasından sana uygun olanı saniyeler içinde bul.') + +@push('styles') + +@endpush + +@section('content') +
    + + {{-- Ambient orbs --}} + + + + {{-- Header --}} +
    + + + +
    + Keşfet + Sana uygun animeyi bul +
    + +
    + + {{-- Filter panel --}} +
    +
    + +
    + + @foreach($genres as $g) + + @endforeach +
    +
    +
    + +
    + + + + +
    +
    +
    + + {{-- Progress --}} +
    +
    +
    +
    + +
    + + {{-- Card stack --}} +
    +
    +
    +
    + + {{-- Floating icons --}} +
    + + {{-- Logo --}} + + + {{-- Spinning rings + center icon --}} +
    +
    +
    + +
    + + {{-- Cycling messages --}} +
    + Animeler hazırlanıyor... +
    +
    Zevklerine göre seçiyoruz
    + + {{-- Dots --}} +
    +
    +
    +
    +
    + +
    +
    + {{-- Results screen (replaces empty state) --}} + +
    +
    + + {{-- Açıklama paneli --}} + + + {{-- Action buttons --}} +
    + + + +
    + + {{-- Keyboard hint --}} +
    + Geç + Detaylar + Listeme Ekle +
    + +
    + +{{-- ───────────────────────────────────────────────── + BOTTOM SHEET +───────────────────────────────────────────────── --}} +
    + + +{{-- Match celebration overlay --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/frontend/genre.blade.php b/resources/views/frontend/genre.blade.php new file mode 100644 index 0000000..90106e4 --- /dev/null +++ b/resources/views/frontend/genre.blade.php @@ -0,0 +1,96 @@ +@extends('frontend.layouts.app') +@php + $__genreCanon = rtrim(\App\Models\Setting::get('seo_canonical_domain', config('app.url')), '/') . route('genre', $genre->slug, false); + $__genreDesc = $genre->name . ' türündeki en iyi anime dizileri ve filmlerini Animexe\'de Türkçe altyazılı veya dublajlı ücretsiz izleyin. ' . $genre->name . ' anime listesi.'; +@endphp +@section('title', $genre->name . ' Anime İzle - Türkçe Altyazılı | Animexe') +@section('meta_description', $__genreDesc) +@section('meta_keywords', $genre->name . ' anime izle, ' . $genre->name . ' türkçe anime izle, ' . $genre->name . ' anime, türkçe ' . $genre->name . ' anime, animexe ' . strtolower($genre->name)) +@section('og_title', $genre->name . ' Türündeki Animeler — Animexe') +@section('og_description', $__genreDesc) +@section('canonical', $__genreCanon) +@section('schema') +@if((\App\Models\Setting::get('seo_enable_schema','1')) === '1') +@php $__gDomain = rtrim(\App\Models\Setting::get('seo_canonical_domain',config('app.url')),'/'); @endphp + +{{-- CollectionPage + ItemList --}} + + +{{-- BreadcrumbList --}} +@if((\App\Models\Setting::get('seo_enable_breadcrumb','1')) === '1') + +@endif +@endif +@endsection + +@section('content') +
    +
    + + + {{ $genre->name }} + + {{ $animes->total() }} anime +
    + + @if($animes->count()) + + @if($animes->hasPages()) +
    {{ $animes->links('frontend.pagination') }}
    + @endif + @else +
    + +

    Bu türde henüz anime bulunmuyor.

    +
    + @endif +
    +@endsection diff --git a/resources/views/frontend/home.blade.php b/resources/views/frontend/home.blade.php new file mode 100644 index 0000000..4cf066d --- /dev/null +++ b/resources/views/frontend/home.blade.php @@ -0,0 +1,2982 @@ +@extends('frontend.layouts.app') +@section('title', \App\Models\Setting::get('seo_home_title', 'Animexe — Türkçe Anime İzle | Ücretsiz HD')) +@section('meta_description', \App\Models\Setting::get('seo_home_description', 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz HD kalitede izleyin.')) +@section('meta_keywords', \App\Models\Setting::get('seo_home_keywords', 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime izle, hd anime')) +@section('og_type', 'website') +@section('canonical', rtrim(\App\Models\Setting::get('seo_canonical_domain', config('app.url')), '/') . '/') +@section('preload') +@if(isset($featured) && $featured->isNotEmpty() && $featured->first()->bannerUrl) + +@endif +@endsection + +@push('styles') + +@endpush + +@section('content') + +{{-- ═══ HERO ══════════════════════════════════════════════════════ --}} +@if($featured->count()) +
    + +
    + @foreach($featured as $i=>$a) +
    + @endforeach +
    + +
    + @php $a0=$featured->first() @endphp +
    + @if($a0->status==='ongoing') + Devam Ediyor + @elseif($a0->status==='upcoming') + Yakında + @endif + @foreach(($a0->genres ?? collect())->take(3) as $g) + {{ $g->name }} + @endforeach +
    +

    {{ $a0->title }}

    +
    + @if($a0->rating){{ number_format($a0->rating,1) }}@endif + @if($a0->release_year){{ $a0->release_year }}@endif + @if($a0->episode_count){{ $a0->episode_count }} Bölüm@endif + @if($a0->studio){{ $a0->studio }}@endif +
    + @if($a0->description) +

    {{ $a0->description }}

    + @endif +
    + @php + $s0=$a0->seasons->sortBy('season_number')->first(); + $e0=$s0?$a0->episodes->where('season_id',$s0->id)->where('is_published',true)->sortBy('episode_number')->first():null; + @endphp + @if($s0&&$e0) + + İzlemeye Başla + + @endif + + Detaylar + +
    +
    + + @if($featured->count()>1) + + + @endif + +
    + @if($featured->first()?->coverUrl) + {{ $featured->first()->title }} + @endif +
    + + @if($featured->count()>1) +
    + @foreach($featured as $i=>$a) + + @endforeach +
    + @endif + +
    +
    +@endif + +{{-- ═══ TICKER ══════════════════════════════════════════════════════ --}} +@php + $tickerEps = \App\Models\Episode::with(['anime','season']) + ->where('is_published',true) + ->latest()->take(16)->get() + ->filter(fn($e) => $e->anime && $e->season); + $features = [ + ['icon'=>'check-circle-fill','color'=>'#4ade80','text'=>'Türkçe Altyazı'], + ['icon'=>'stars','color'=>'#c084fc','text'=>'AI Anime Önerileri'], + ['icon'=>'shield-fill-check','color'=>'#00f5ff','text'=>'Reklamsız İzle'], + ['icon'=>'bell-fill','color'=>'#ffd60a','text'=>'Yeni Bölüm Bildirimi'], + ['icon'=>'trophy-fill','color'=>'#fb923c','text'=>'Başarım Sistemi'], + ['icon'=>'bookmark-heart-fill','color'=>'#f472b6','text'=>'Kişisel İzleme Listesi'], + ['icon'=>'camera-video-fill','color'=>'#60a5fa','text'=>'HD Kalite'], + ]; +@endphp +
    +
    Son Dakika
    +
    + @for($pass=0;$pass<2;$pass++) +
    + @foreach($tickerEps as $i=>$ep) + + YENİ + {{ $ep->anime->title }} + S{{ str_pad($ep->season->season_number,2,'0',STR_PAD_LEFT) }}E{{ str_pad($ep->episode_number,2,'0',STR_PAD_LEFT) }} + + + @if($i % 3 === 2) + @php $feat = $features[$i % count($features)]; @endphp + {{ $feat['text'] }} + + @endif + @endforeach +
    + @endfor +
    +
    + +{{-- ═══ GENRE NAV STRIP ════════════════════════════════════════════ --}} +@php +$_gNavColors = [ + 'Aksiyon'=>'#ff4d4d','Macera'=>'#00c6ff','Komedi'=>'#ffd60a','Drama'=>'#ff7eb3', + 'Fantezi'=>'#c084fc','Korku'=>'#ff6b2b','Romantik'=>'#ff69b4','Bilim Kurgu'=>'#00f5ff', + 'Spor'=>'#4ade80','Gerilim'=>'#f97316','Ecchi'=>'#fb7185','Slice of Life'=>'#86efac', + 'Psikolojik'=>'#a78bfa','Doğaüstü'=>'#e879f9','Mecha'=>'#60a5fa','Müzik'=>'#f472b6', + 'Tarih'=>'#fbbf24','Okul'=>'#34d399', +]; +$_gNavIcons = [ + 'Aksiyon'=>'lightning-charge-fill','Macera'=>'compass-fill','Komedi'=>'emoji-laughing-fill', + 'Drama'=>'heart-fill','Fantezi'=>'stars','Korku'=>'eye-fill','Romantik'=>'suit-heart-fill', + 'Bilim Kurgu'=>'rocket-takeoff-fill','Spor'=>'trophy-fill','Gerilim'=>'exclamation-diamond-fill', + 'Ecchi'=>'fire','Slice of Life'=>'sun-fill','Psikolojik'=>'person-fill','Doğaüstü'=>'magic', + 'Mecha'=>'cpu-fill','Müzik'=>'music-note-beamed','Tarih'=>'hourglass-split','Okul'=>'book-fill', +]; +@endphp +
    +
    + @foreach($genres as $g) + @php $gc = $_gNavColors[$g->name] ?? '#00f5ff'; $gi = $_gNavIcons[$g->name] ?? 'tag'; @endphp + + {{ $g->name }} + + @endforeach +
    +
    + +{{-- ═══ SPOTLIGHT — 3 büyük öne çıkan kart (NEW) ════════════════ --}} +@if($trending->count() >= 3) +
    +
    +
    + Öne Çıkanlar +
    +
    + @foreach($trending->take(3) as $anime) + @php + $sSp=$anime->seasons->sortBy('season_number')->first(); + $eSp=$sSp?$anime->episodes->where('season_id',$sSp->id)->where('is_published',true)->sortBy('episode_number')->first():null; + $urlSp=($sSp&&$eSp&&$anime->slug)?route('watch',[$anime->slug,$sSp->season_number,$eSp->episode_number]):$anime->detailUrl; + @endphp + + @if($anime->bannerUrl ?? $anime->coverUrl) + {{ $anime->title }} + @endif +
    +
    + @if($anime->status==='ongoing')Devam Ediyor@elseif($anime->type==='movie')Film@endif +
    {{ $anime->title }}
    +
    + @if($anime->rating){{ number_format($anime->rating,1) }}@endif + @if($anime->release_year){{ $anime->release_year }}@endif + @foreach(($anime->genres ?? collect())->take(2) as $g)· {{ $g->name }}@endforeach +
    +
    +
    + @endforeach +
    +
    +
    +@endif + +{{-- ═══ 1. TREND (RANKED) ════════════════════════════════════════ --}} +@if($trending->count()) + +@endif + +{{-- ═══ TÜRKÇE DUBLAJ ═════════════════════════════════════════ --}} +@if(isset($dubbed) && $dubbed->count()) + +@endif + +{{-- ── Banner Reklam (orta) ─────────────────────────────────────────────── --}} +@include('frontend.partials.ad-banner', ['ad' => $bannerAds['home_mid'] ?? null]) + +{{-- ── Instagram Follow Banner ──────────────────────────────────────────── --}} + +
    +
    +
    + + + + + + + + + + + + + + +
    +
    + Bizi Instagram'da Takip Et + @animexecom + Yeni animeler, duyurular ve daha fazlası +
    +
    +
    + Takip Et + +
    + + +
    +
    + +{{-- ═══ DEVAM ET ════════════════════════════════════════════════ --}} +@if($continueWatching->count()) + +@endif + +{{-- ═══ 2. EN YÜKSEK PUAN ════════════════════════════════════════ --}} +@if($topRated->count()) + +@endif + +{{-- ── Banner Reklam (alt) ──────────────────────────────────────────────── --}} +@include('frontend.partials.ad-banner', ['ad' => $bannerAds['home_bottom'] ?? null]) + +@if(!empty($bannerAds['home_mid']) || !empty($bannerAds['home_bottom'])) + +@endif + +{{-- ═══ KEŞFİYAT FEATURE SECTION ══════════════════════════════════ --}} +
    +
    + + {{-- Left: text --}} +
    + Yeni Özellik +

    + Anime Keşfet +

    +

    Kaydır, beğen, listene ekle. Binlerce anime arasından sana uygun olanı saniyeler içinde bul — yapay zeka açıklamalarıyla.

    +
    + Swipe ile keşfet + AI tanıtımları + Listene ekle +
    + + Hemen Dene + + +
    + + {{-- Right: animated card stack --}} + + +
    +
    + +{{-- ═══ SANA ÖZEL ════════════════════════════════════════════════ --}} +@if($recommended->count()) + +@endif + +{{-- ═══ 3. YENİ EKLENENLER ════════════════════════════════════════ --}} +@if($latest->count()) + +@endif + +{{-- ═══ 4. BU SEZON DEVAM EDİYOR ════════════════════════════════ --}} +@if($ongoing->count()) + +@endif + +{{-- ═══ PROMO BANNER ════════════════════════════════════════════ --}} +@if(!auth()->check() || !auth()->user()->isPremium()) +
    +
    +
    +
    ✦ Animexe Premium
    +
    Her Zaman, Her Yerde
    Türkçe Anime
    +
    Binlerce anime, yüzlerce bölüm — reklamsız, yüksek kalitede. Hemen keşfet.
    +
    + + Keşfet + +
    +
    +@endif + +{{-- ═══ POPÜler FİLMLER ════════════════════════════════════════ --}} +@if($popularMovies->count()) +
    +
    +
    + Popüler Anime Filmleri + Tümü → +
    +
    + @foreach($popularMovies as $anime) + @php + $firstSeason = $anime->seasons()->orderBy('season_number')->first(); + $firstEp = $firstSeason ? $firstSeason->episodes()->where('is_published',true)->orderBy('episode_number')->first() : null; + $watchUrl = ($firstSeason && $firstEp && $anime->slug) ? route('watch',[$anime->slug,$firstSeason->season_number,$firstEp->episode_number]) : $anime->detailUrl; + @endphp + +
    + @if($anime->bannerUrl ?? $anime->coverUrl) + {{ $anime->title }} + @else
    @endif + Film + @if($anime->rating){{ number_format($anime->rating,1) }}@endif +
    +
    +
    +
    {{ $anime->title }}
    + @if($anime->description)
    {{ $anime->description }}
    @endif +
    {{ $anime->release_year }}@if($anime->studio) · {{ $anime->studio }}@endif
    +
    +
    + @endforeach +
    +
    +
    +@endif + +{{-- ═══ TÜR SPOTLIGHT ══════════════════════════════════════════ --}} +@if($genreSpotlights->count()) +@php +$spotColors=['Aksiyon'=>['#ff4d4d','fire'],'Fantezi'=>['#c084fc','stars'],'Romantik'=>['#f472b6','suit-heart-fill'], + 'Psikolojik'=>['#a78bfa','person-bounding-box'],'Komedi'=>['#ffd60a','emoji-laughing-fill'], + 'Spor'=>['#4ade80','trophy-fill'],'Macera'=>['#00c6ff','compass-fill'],'Drama'=>['#ff7eb3','heart-fill']]; +@endphp +@foreach($genreSpotlights as $spot) +@php $g=$spot['genre']; $col=($spotColors[$g->name]??['#00f5ff','tag'])[0]; $ico=($spotColors[$g->name]??['#00f5ff','tag'])[1]; @endphp + +@endforeach +@endif + +{{-- ═══ YENİ EKLENEN BÖLÜMLER ════════════════════════════════════ --}} +@if($newEpisodes->count()) + +@endif + +{{-- ═══ TÜRLER ════════════════════════════════════════════════════ --}} +@if($genres->count()) +
    +
    +
    +
    + Türe Göre Keşfet + Sevdiğin türü seç, binlerce anime seni bekliyor +
    +
    + @php + $genreIcons=[ + 'Aksiyon'=>'lightning-charge-fill','Macera'=>'compass-fill','Komedi'=>'emoji-laughing-fill', + 'Drama'=>'heart-fill','Fantezi'=>'stars','Korku'=>'eye-fill','Romantik'=>'suit-heart-fill', + 'Bilim Kurgu'=>'rocket-takeoff-fill','Spor'=>'trophy-fill','Gerilim'=>'exclamation-diamond-fill', + 'Ecchi'=>'fire','Slice of Life'=>'sun-fill','Psikolojik'=>'person-fill','Doğaüstü'=>'magic', + 'Mecha'=>'cpu-fill','Müzik'=>'music-note-beamed','Tarih'=>'hourglass-split','Okul'=>'book-fill', + ]; + $genreColors=[ + 'Aksiyon'=>'#ff4d4d','Macera'=>'#00c6ff','Komedi'=>'#ffd60a','Drama'=>'#ff7eb3', + 'Fantezi'=>'#c084fc','Korku'=>'#ff6b2b','Romantik'=>'#ff69b4','Bilim Kurgu'=>'#00f5ff', + 'Spor'=>'#4ade80','Gerilim'=>'#f97316','Ecchi'=>'#fb7185','Slice of Life'=>'#86efac', + 'Psikolojik'=>'#a78bfa','Doğaüstü'=>'#e879f9','Mecha'=>'#60a5fa','Müzik'=>'#f472b6', + ]; + @endphp +
    + @foreach($genres as $g) + @php $icon=$genreIcons[$g->name]??'tag'; $col=$genreColors[$g->name]??'#00f5ff'; @endphp + +
    + +
    +
    +
    {{ $g->name }}
    + @if($g->animes_count ?? null)
    {{ $g->animes_count }} anime
    @endif +
    + +
    + @endforeach +
    +
    +
    +@endif + +
    + +@if(!empty($apkUrl)) +
    +
    +
    +
    + + + + + +
    +
    + Animexe Mobil Uygulaması + Reklamsız izle · Offline indir · NicoNico yorumlar · Watch Party +
    +
    + + + APK İndir + +
    +
    +@endif + +@endsection + +@push('styles') + +@endpush + +@push('scripts') + + +@if(auth()->check() && $userWatchTitles) + +@endif + +@endpush diff --git a/resources/views/frontend/layouts/app.blade.php b/resources/views/frontend/layouts/app.blade.php new file mode 100644 index 0000000..e6a4bd3 --- /dev/null +++ b/resources/views/frontend/layouts/app.blade.php @@ -0,0 +1,3431 @@ + + + + + + + +@php + // SEO ayarlarını cache'den yükle + $__seo = cache()->remember('seo_settings', 3600, fn() => + \App\Models\Setting::where('key','like','seo_%')->pluck('value','key')->toArray() + ); + $__seoSite = $__seo['seo_site_name'] ?? 'Animexe'; + $__seoDefDesc = $__seo['seo_home_description'] ?? 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz HD kalitede izleyin.'; + $__seoDefKw = $__seo['seo_home_keywords'] ?? 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime'; + $__seoOgImg = url($__seo['seo_og_image'] ?? '/logo.jpg'); + $__seoDomain = rtrim($__seo['seo_canonical_domain'] ?? config('app.url'), '/'); + $__seoCanon = $__seoDomain . '/' . ltrim(request()->path(), '/'); + $__seoTwitter = $__seo['seo_twitter_site'] ?? ''; + $__seoGa = $__seo['seo_google_analytics'] ?? ''; + $__seoGtm = $__seo['seo_gtm_id'] ?? ''; + $__seoGsc = $__seo['seo_gsc_verification'] ?? ''; + $__seoBing = $__seo['seo_bing_verification'] ?? ''; + $__seoYandex = $__seo['seo_yandex_verification'] ?? ''; + $__seoSchema = ($__seo['seo_enable_schema'] ?? '1') === '1'; + // Sayfa başlığı ve diğerleri (child template section'ları okuma) + $__pageTitle = $__env->yieldContent('title', $__seoSite . ' — Türkçe Anime İzle'); + $__pageDesc = $__env->yieldContent('meta_description', $__seoDefDesc); + $__pageKw = $__env->yieldContent('meta_keywords', $__seoDefKw); + $__pageRobots = $__env->yieldContent('robots', 'index, follow'); + $__pageCanon = $__env->yieldContent('canonical', $__seoCanon); + $__pageOgType = $__env->yieldContent('og_type', 'website'); + $__pageOgTitle = $__env->yieldContent('og_title', $__pageTitle); + $__pageOgDesc = $__env->yieldContent('og_description', $__pageDesc); + $__pageOgImg = $__env->yieldContent('og_image', $__seoOgImg); + $__pageOgImgW = $__env->yieldContent('og_image_width', '1200'); + $__pageOgImgH = $__env->yieldContent('og_image_height', '630'); +@endphp +{{-- LCP Preload: sayfaya özgü kritik görseller --}} +@yield('preload') +{{ $__pageTitle }} +{{-- Temel Meta --}} + + + + + + +@php $__favicon = \App\Models\Setting::get('site_favicon','') @endphp +@if($__favicon)@else@endif + + +{{-- Preconnect: CDN ve harici kaynaklar --}} + + + + +{{-- Doğrulama Etiketleri --}} +@if($__seoGsc)@endif +@if($__seoBing)@endif +@if($__seoYandex)@endif +{{-- Open Graph --}} + + + + + + + + + +@if(!empty($__seo['seo_facebook_app_id']))@endif +{{-- Twitter Card --}} + +@if($__seoTwitter)@endif + + + +{{-- JSON-LD: WebSite Schema (sitewide) --}} +@if($__seoSchema) + +@if(!empty($__seo['seo_org_logo']) || !empty($__seo['seo_org_twitter'])) + +@endif +@endif +{{-- Sayfaya özgü JSON-LD --}} +@yield('schema') +{{-- Google Analytics 4 --}} +@if($__seoGtm) + +@elseif($__seoGa) + + +@endif + + + + + + + +@stack('styles') + +user()->entry_effect && auth()->user()->hasPerk('entry_effect'))class="entry-{{ auth()->user()->entry_effect }}"@endif @endauth> + +{{-- Ambient floating orbs (CSS animated, no perf cost) --}} + + + + +{{-- Scroll progress bar --}} + + +{{-- Scroll to top button --}} + + +
    + +
    +
    +
    + + + +
    + @auth +
    +
    {{ mb_substr(auth()->user()->name,0,1) }}
    + +
    + @endauth + + + + + + @auth +
    + + @else + + @endauth +
    + +{{-- ═══════════════════════════════════════════════════════ + MOBİL ALT NAVİGASYON ÇUBUĞU (90% mobil ziyaretçi) +════════════════════════════════════════════════════════ --}} + + +{{-- ════════════════════════════════════════════════════ + CHAT PANEL (site genelinde erişilebilir) +════════════════════════════════════════════════════ --}} +
    +
    + + {{-- Konuşma listesi --}} +
    +
    + Mesajlar + + + + +
    +
    +
    Yükleniyor…
    +
    + + Tüm Mesajlar / Yeni Sohbet + +
    + + {{-- Sohbet ekranı --}} +
    +
    + +
    +
    +
    +
    + @auth + + @endauth + + + + +
    +
    +
    + {{-- Emoji picker (desktop only) --}} +
    + {{-- GIF picker --}} +
    + +
    +
    GIF aramak için yazmaya başla
    +
    +
    + {{-- Toolbar: only desktop emoji + both gif/image --}} +
    + + + +
    +
    +
    + + +
    +
    +
    + +
    + +{{-- ═══ INCOMING CALL OVERLAY ════════════════════════════════════════════════ --}} +
    +
    +
    +
    Sesli arama…
    +
    + + +
    +
    + +{{-- Active call bar --}} +
    +
    + + 00:00 + +
    + + +
    @yield('content')
    + + + + +@stack('scripts') + +{{-- ── Search Autocomplete ──────────────────────────────────────────────────── --}} + + +@auth +{{-- ── Floating AI Chat Widget ─────────────────────────────────── --}} + +
    + +
    +
    +
    +
    +
    +
    Animexe AI
    +
    Aktif & hazır
    +
    +
    + + +
    +
    + +
    +
    +
    Merhaba! 👋 Anime önerisi isteyebilir, site hakkında sorular sorabilir ya da izlediğin bölüm hakkında konuşabiliriz. Nasıl yardımcı olabilirim?
    +
    +
    +
    + + + + + +
    +
    + + +
    +
    + +@endauth + +{{-- ── Sayfa takip sistemi ────────────────────────────────────────────────── --}} + + +{{-- ── Page Transition Loader ──────────────────────────────────────── --}} + + +
    +
    +
    +
    +
    +
    +
    + + + +{{-- ── Global Kullanıcı Kart Popup ─────────────────────────────── --}} + +
    + + + + + + + +{{-- ── Global Toast Notification System ──────────────────────── --}} + +
    + + +{{-- ═══════════════════════════════════════════════════════════════ + CHAT PANEL JS v2 +═══════════════════════════════════════════════════════════════ --}} +@auth + +@endauth + +{{-- Crisp Chat --}} + + + + diff --git a/resources/views/frontend/legal/dmca.blade.php b/resources/views/frontend/legal/dmca.blade.php new file mode 100644 index 0000000..5d09e69 --- /dev/null +++ b/resources/views/frontend/legal/dmca.blade.php @@ -0,0 +1,118 @@ +@extends('frontend.layouts.app') +@section('title', 'Telif Hakkı & DMCA | Animexe') +@push('styles') + +@endpush +@section('content') +
    + + + + +
    +@endsection diff --git a/resources/views/frontend/legal/privacy.blade.php b/resources/views/frontend/legal/privacy.blade.php new file mode 100644 index 0000000..cd1135a --- /dev/null +++ b/resources/views/frontend/legal/privacy.blade.php @@ -0,0 +1,152 @@ +@extends('frontend.layouts.app') +@section('title', 'Gizlilik Politikası | Animexe') +@push('styles') + +@endpush +@section('content') +
    + + + + +
    +@endsection diff --git a/resources/views/frontend/legal/terms.blade.php b/resources/views/frontend/legal/terms.blade.php new file mode 100644 index 0000000..dc026a4 --- /dev/null +++ b/resources/views/frontend/legal/terms.blade.php @@ -0,0 +1,141 @@ +@extends('frontend.layouts.app') +@section('title', 'Kullanım Koşulları | Animexe') +@push('styles') + +@endpush +@section('content') +
    + + + + +
    +@endsection diff --git a/resources/views/frontend/messages/index.blade.php b/resources/views/frontend/messages/index.blade.php new file mode 100644 index 0000000..4510003 --- /dev/null +++ b/resources/views/frontend/messages/index.blade.php @@ -0,0 +1,103 @@ +@extends('frontend.layouts.app') +@section('title', 'Mesajlar — Animexe') +@push('styles') + +@endpush + +@section('content') + +@endsection diff --git a/resources/views/frontend/messages/show.blade.php b/resources/views/frontend/messages/show.blade.php new file mode 100644 index 0000000..15b06fc --- /dev/null +++ b/resources/views/frontend/messages/show.blade.php @@ -0,0 +1,655 @@ +@extends('frontend.layouts.app') +@section('title', ($other?->name ?? 'Mesaj') . ' — Animexe') +@push('styles') + +@endpush + +@section('content') +@php $me = auth()->user(); @endphp +
    + + {{-- Header --}} +
    + + + + + @if($other?->avatar) + {{ $other->name }} + @else + {{ mb_strtoupper(mb_substr($other?->name ?? '?', 0, 1)) }} + @endif + +
    +
    {{ $other?->name ?? 'Silinmiş Kullanıcı' }}
    + @if($other?->username) +
    @php echo '@' . e($other->username); @endphp
    + @endif +
    + @if($other) + {{-- Sesli arama --}} + + {{-- Profil --}} + + + + @endif +
    + + {{-- Messages --}} +
    + @if($messages->isEmpty()) +
    + +

    Henüz mesaj yok. İlk mesajı sen gönder!

    +
    + @else + @php $lastDate = null; @endphp + @foreach($messages as $msg) + @php + $msgDate = $msg->created_at->toDateString(); + $isMine = $msg->user_id === $me->id; + @endphp + @if($msgDate !== $lastDate) +
    {{ $msg->created_at->translatedFormat('d F Y') }}
    + @php $lastDate = $msgDate; @endphp + @endif +
    + @if(!$isMine) +
    + @if($msg->user?->avatar) + + @else{{ mb_strtoupper(mb_substr($msg->user?->name ?? '?', 0, 1)) }}@endif +
    + @endif +
    + @if(str_starts_with($msg->body, 'ANIMESHARE::')) + @php try { $sd = json_decode(substr($msg->body,12),true); } catch(\Throwable $e){ $sd=null; } @endphp + @if($sd) + + @else +
    {{ $msg->body }}
    + @endif + @elseif(str_starts_with($msg->body, 'IMAGE::')) + Resim + @elseif(str_starts_with($msg->body, 'GIF::')) + GIF + @else +
    {{ $msg->body }}
    + @endif +
    + {{ $msg->created_at->format('H:i') }} +
    + @endforeach + @endif +
    + + {{-- Footer --}} + +
    +@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/frontend/notifications.blade.php b/resources/views/frontend/notifications.blade.php new file mode 100644 index 0000000..b47f5f1 --- /dev/null +++ b/resources/views/frontend/notifications.blade.php @@ -0,0 +1,82 @@ +@extends('frontend.layouts.app') +@section('title', 'Bildirimler — Animexe') + +@push('styles') + +@endpush + +@section('content') +
    +
    +

    Bildirimler

    +

    Takip ettiğin animelerin yeni bölüm bildirimleri burada görünür.

    +
    + + @if($notifications->isEmpty()) +
    + +
    Henüz bildiriminiz yok.
    +
    Anime sayfalarından animeleri takip edin, yeni bölüm eklenince bilgilendirilirsiniz.
    +
    + @else + @foreach($notifications as $n) + @php + $d = $n->data; + $isEp = $n->type === 'episode'; + $isAdmin = $n->type === 'admin'; + $url = $isEp && isset($d['anime_slug']) + ? route('watch', [$d['anime_slug'], $d['season_number'] ?? 1, $d['episode_number'] ?? 1]) + : ($isAdmin && !empty($d['url']) ? $d['url'] : '#'); + @endphp + +
    +
    + +
    +
    + @if($isEp) +
    {{ $d['anime_title'] ?? 'Anime' }} — Yeni Bölüm!
    +
    + Sezon {{ $d['season_number'] ?? 1 }}, {{ $d['episode_number'] ?? 1 }}. Bölüm eklendi + @if(!empty($d['episode_title'])) — {{ $d['episode_title'] }}@endif +
    + @else +
    {{ $d['title'] ?? 'Bildirim' }}
    + @if(!empty($d['body']))
    {{ $d['body'] }}
    @endif + @endif +
    {{ $n->created_at ? \Carbon\Carbon::parse($n->created_at)->diffForHumans() : '' }}
    +
    + @if(!$n->is_read) +
    + @endif +
    +
    + @endforeach + +
    {{ $notifications->links() }}
    + @endif +
    +@endsection diff --git a/resources/views/frontend/pagination.blade.php b/resources/views/frontend/pagination.blade.php new file mode 100644 index 0000000..26c4872 --- /dev/null +++ b/resources/views/frontend/pagination.blade.php @@ -0,0 +1,70 @@ +@if ($paginator->hasPages()) + + +@endif diff --git a/resources/views/frontend/partials/ad-banner.blade.php b/resources/views/frontend/partials/ad-banner.blade.php new file mode 100644 index 0000000..37cfe00 --- /dev/null +++ b/resources/views/frontend/partials/ad-banner.blade.php @@ -0,0 +1,49 @@ +{{-- Sade banner reklam — $ad: App\Models\Ad + NOT: class/attribute isimleri bilerek nötr ("ad"/"banner" içermez) — + adblocker'lar .ad-banner / [data-ad-id] gibi seçicileri cosmetic filter ile gizliyor. + Ayrıca .home-section KULLANMA — o class'ta opacity:0 (scroll reveal) var. --}} +@if($ad && $ad->media_url) + +@once + +@endonce + +
    +
    +
    Sponsorlu
    + @if($ad->click_url) + + {{ $ad->name }} + + @else +
    + {{ $ad->name }} +
    + @endif +
    +
    +@endif diff --git a/resources/views/frontend/player.blade.php b/resources/views/frontend/player.blade.php new file mode 100644 index 0000000..a0f3686 --- /dev/null +++ b/resources/views/frontend/player.blade.php @@ -0,0 +1,5581 @@ +@extends('frontend.layouts.app') +@php + $__epTitle = $anime->title . ' ' . $seasonModel->season_number . '. Sezon ' . $ep->episode_number . '. Bölüm İzle'; + $__epDesc = $anime->title . ' ' . $seasonModel->season_number . '. sezon ' . $ep->episode_number . '. bölümünü Türkçe altyazılı ve dublajlı ücretsiz izle — Animexe.'; + $__epImgUrl = $anime->coverUrl ?? url('/logo.jpg'); + $__noindexWp = \App\Models\Setting::get('seo_noindex_watch', '0') === '1'; + $__domain = rtrim(\App\Models\Setting::get('seo_canonical_domain', config('app.url')), '/'); +@endphp +@section('title', $__epTitle . ' - Türkçe | Animexe') +@section('meta_description', $__epDesc) +@section('meta_keywords', $anime->title . ' izle, ' . $anime->title . ' ' . $ep->episode_number . '. bölüm izle, türkçe anime izle, animexe') +@section('robots', $__noindexWp ? 'noindex, nofollow' : 'index, follow') +@section('og_title', $__epTitle . ' - Türkçe | Animexe') +@section('og_description', $__epDesc) +@section('og_image', $__epImgUrl) +@section('schema') +@if(\App\Models\Setting::get('seo_enable_schema','1') === '1') + +@endif +@endsection +@push('styles') +{{-- CDN preconnect: HLS segment bağlantıları için önceden bağlan --}} + + + +@endpush + +@section('content') +
    + + {{-- ── Main ───────────────────────────────────────────────────── --}} +
    + + {{-- Ambiyans ışığı canvas — .vw dışında, .pw-main içinde --}} + + +
    +
    + + {{-- IMA Ad Container --}} + @if(!empty($adsConfig['enabled']) && !empty($adsConfig['vast_url'])) + + @endif + + {{-- Intro overlay --}} + @if($introUrl) +
    + + +
    + @endif + + {{-- NicoNico yorum katmanı --}} +
    + + {{-- İlk kez izleyenler badge --}} + + + {{-- Watch Party butonu --}} + @auth + + @endauth + + {{-- Ruh Hali Motoru overlay --}} +
    +
    +
    🎬 Bölüm bitti! Sırada ne izleyelim?
    +
    Ruh halini seç, sana özel öneri getirelim.
    +
    + + + + + + +
    + +
    +
    +
    Önerilen animeler
    +
    + +
    +
    + + {{-- Watermark cover --}} + @if($wmCoverSeconds > 0) +
    ANIMEXE
    + @endif + + {{-- Buffer spinner (mid-play buffering) --}} +
    + + {{-- Loading screen (ilk yükleme + kaynak değişimi) --}} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Yükleniyor
    +
    + + +
    +
    + + {{-- İntro/Outro atla butonu --}} + + + {{-- Most-skipped segment button --}} + + + {{-- Muted chip (autoplay policy — küçük, video'yu bloklamaz) --}} + + + {{-- Subtitle overlay --}} +
    + + {{-- Seek flash --}} +
    10 saniye
    +
    10 saniye
    + + + {{-- Controls --}} +
    + + + {{-- Progress --}} +
    +
    +
    0:00
    +
    +
    +
    +
    +
    +
    + + {{-- Control bar — süre ortada, sağda ayar / tam ekran aynı hizada --}} +
    +
    + @if($prev) + + + + @endif + + + + @if($next) + + + + @endif +
    +
    0:00 / 0:00
    +
    + {{-- Kalite badge: sadece landscape'te görünür --}} +
    + +
    + @if(count($videoSourcesData ?? []) > 1) +
    +
    Çeviri / Kaynak
    + @foreach($videoSourcesData as $idx => $vs) +
    + + @if(!empty($vs['featured'])) + ⭐ {{ $vs['label'] }} + @else + {{ $vs['label'] }} + @endif +
    + @endforeach +
    +
    + @endif + @if(count($dubSources) > 1) +
    +
    Dublaj / Ses
    + @foreach($dubSources as $d) +
    + {{ $d['label'] }} +
    + @endforeach +
    +
    + @endif + @if(count($subtitlesData) > 0) +
    Altyazı
    +
    + Kapalı +
    + @foreach($subtitlesData as $i => $s) +
    + {{ $s['label'] }} +
    + @endforeach +
    Altyazı Görünümü
    +
    +
    + Boyut +
    + + + + +
    +
    +
    + Renk +
    + + + + + +
    +
    +
    + Arka plan +
    + + + +
    +
    +
    +
    + @endif +
    Video Kalitesi
    +
    + Otomatik +
    + {{-- HLS levels injected by JS --}} +
    +
    +
    Oynatma Hızı
    + @foreach([0.5,0.75,1,1.25,1.5,2] as $spd) +
    + {{ $spd }}x +
    + @endforeach +
    +
    Video Zoom
    +
    + Ekranı Doldur +
    +
    + +
    + Normal + 100% + +
    +
    +
    +
    Görsel
    +
    Ambiyans Işığı
    +
    +
    + + {{-- Altyazı: her zaman görünür, ayrı CC butonu --}} + @if(count($subtitlesData) > 0) +
    + +
    +
    Altyazı
    +
    + Kapalı +
    + @foreach($subtitlesData as $i => $s) +
    + {{ $s['label'] }} +
    + @endforeach +
    +
    + @endif + + {{-- Dub: sadece geniş ekranda ayrı buton --}} + @if(count($dubSources) > 1) +
    + +
    +
    Dublaj / Dil
    + @foreach($dubSources as $d) +
    + {{ $d['label'] }} +
    + @endforeach +
    +
    + @endif +
    + + +
    + + +
    +
    +
    + + {{-- Next ep --}} + @if($next) +
    +
    +
    Sıradaki Bölüm
    + +
    +
    {{ $next->episode_number }}. {{ $next->title ?: 'Bölüm' }}
    +
    + Sonraki Bölüme Geç +
    + @endif +
    + + {{-- NicoNico yorum giriş satırı --}} + @auth +
    + + + + + +
    + @endauth + + {{-- Kaynak Seçici Tab Bar --}} + @if(count($videoSourcesData ?? []) > 1) +
    +
    Çeviri / Kaynak
    +
    + @foreach($videoSourcesData as $idx => $vs) + + @endforeach +
    +
    + + + @endif + + {{-- Info bar --}} +
    +
    +
    +
    + {{ $anime->title }} +  · {{ $seasonModel->season_number }}. Sezon +
    +

    + {{ $ep->episode_number }}. Bölüm{{ $ep->title ? ' — '.$ep->title : '' }} +

    +
    + {{ number_format($ep->view_count) }} + @if($ep->duration){{ $ep->duration_formatted }}@endif + @if($activeDub){{ ['trdub'=>'TR Dublaj','original'=>'Japonca','endub'=>'EN Dublaj'][$activeDub]??$activeDub }}@endif + @if($anime->rating){{ number_format($anime->rating,1) }}@endif +
    +
    +
    + @auth + + @endauth + @if($prev) + + Önceki + + @endif + @if($next) + + Sonraki + + @endif +
    +
    +
    + + {{-- ── Tabs + Content ─────────────────────────────────────────── --}} +
    + + {{-- Tab nav --}} +
    + + + @auth + + + @else + + @endauth + +
    + + {{-- ── Info Panel ──────────────────────────────────────────── --}} +
    +
    + +
    + {{-- Anime posteri --}} + @if($anime->coverUrl) +
    + {{ $anime->title }} +
    + @endif + +
    +
    + {{ $anime->title }} +  ·  {{ $seasonModel->season_number }}. Sezon +
    +
    + {{ $ep->episode_number }}. Bölüm{{ $ep->title ? ' — '.$ep->title : '' }} +
    + +
    +
    {{ number_format($ep->view_count) }} izlenme
    + @if($ep->duration)
    {{ $ep->duration_formatted }}
    @endif + @if($activeDub)
    {{ ['trdub'=>'Türkçe Dublaj','original'=>'Japonca (Orijinal)','endub'=>'İngilizce Dublaj'][$activeDub] ?? $activeDub }}
    @endif + @if($anime->rating)
    {{ number_format($anime->rating,1) }}
    @endif +
    + + {{-- Like / Dislike --}} + @php + $epLikes = \App\Models\EpisodeVote::where('episode_id',$ep->id)->where('vote',1)->count(); + $epDislikes = \App\Models\EpisodeVote::where('episode_id',$ep->id)->where('vote',-1)->count(); + $myVote = auth()->check() ? \App\Models\EpisodeVote::where('user_id',auth()->id())->where('episode_id',$ep->id)->value('vote') : null; + @endphp +
    + @auth + + + @else + + {{ $epLikes }} + + + {{ $epDislikes }} + + @endauth +
    + + @if($ep->description) +
    +
    Bölüm Hakkında
    +

    {{ $ep->description }}

    + @if(mb_strlen($ep->description) > 220) + + @endif +
    + @elseif($anime->description) +
    +
    Anime Hakkında
    +

    {{ $anime->description }}

    + @if(mb_strlen($anime->description) > 220) + + @endif +
    + @endif + + {{-- Türler --}} + @if($anime->genres && $anime->genres->count()) +
    + @foreach($anime->genres->take(6) as $g) + {{ $g->name }} + @endforeach +
    + @endif +
    +
    + + {{-- Anime meta --}} +
    + @if($anime->studio) +
    + Stüdyo + {{ $anime->studio }} +
    + @endif + @if($anime->release_year) +
    + Yıl + {{ $anime->release_year }} +
    + @endif + @if($anime->status) +
    + Durum + {{ ['ongoing'=>'Devam Ediyor','completed'=>'Tamamlandı','upcoming'=>'Yakında'][$anime->status] ?? $anime->status }} +
    + @endif +
    + Sezon + {{ $anime->seasons->count() }} Sezon · {{ $anime->episode_count }} Bölüm +
    +
    + +
    +
    + + {{-- ── Comments Panel ──────────────────────────────────────── --}} +
    +
    +
    +
    + Yorumlar + +
    + + @auth +
    +
    @if(auth()->user()->avatar)@else{{ mb_substr(auth()->user()->name,0,1) }}@endif
    +
    + + +
    +
    + @if(auth()->check() && auth()->user()->hasPerk('comment_gif')) + + @else + + @endif + +
    +
    +
    + + + @else + + @endauth + +
    + + +
    +
    +
    + + {{-- ── AI Analiz Paneli ──────────────────────────────────────── --}} + @auth +
    +
    + {{-- Bölüm AI analizi --}} +
    +
    +
    +
    +
    Bölüm AI Analizi
    +
    Spoilersız bölüm analizi ve öneriler
    +
    +
    + + + +
    + + {{-- Sohbet bölümü (mini) --}} +
    +
    Bu anime hakkında AI'ya sor
    +
    + @foreach(['Bu animeyi tamamlamalı mıyım?','Benzer anime önerir misin?','Bu bölümde neler oldu?','Anime ne zaman bitti?'] as $q) + + @endforeach +
    +
    + + +
    +
    +
    +
    +
    + {{-- ── Not Paneli ─────────────────────────────────────────────── --}} +
    +
    +
    Bölüm Notları
    +
    + +
    +
    + + +
    +
    +
    +
    + + Henüz not eklemedin. Sahne kaydet, sonra bul! +
    +
    + + @endauth + + {{-- ── Tahmin Paneli (herkese görünür, giriş gerektirir yazma) ── --}} +
    +
    + Bir Sonraki Bölüm Tahminleri +
    + + @auth +
    + + +
    + @else +
    + Giriş yap veya kayıt ol — tahmin yaz +
    + @endauth + +
    + +
    + +
    +
    + + {{-- ── Sidebar ─────────────────────────────────────────────────── --}} +
    + + {{-- Sekmeler --}} +
    + + +
    + + {{-- Bölümler sekmesi --}} +
    +
    + @if($anime->seasons->count() > 1) +
    + @foreach($anime->seasons as $s) + + @endforeach +
    + @endif +
    + +
    + + {{-- Spoiler Kutusu sekmesi --}} +
    +
    +
    + Yükleniyor... +
    +
    + @auth +
    + + +
    + @else +
    + Giriş yap — yorum ekle +
    + @endauth +
    + +
    + +
    + +{{-- ── Share Modal ─────────────────────────────────── --}} +@auth + +@endauth + +{{-- Watch Party Modal --}} +@auth +
    +
    +
    +
    Birlikte İzle — Watch Party
    + +
    +
    +
    +

    Oda oluştur, kodunu arkadaşlarınla paylaş ve aynı anda izleyin.

    + + +
    +
    + + +
    +
    veya
    +
    + +
    + +
    +
    +
    +@endauth + +@endsection + +{{-- Tracking meta tags --}} + + + +@push('scripts') + +@if(!empty($adsConfig['enabled']) && !empty($adsConfig['vast_url'])) + +@endif + + +@auth + +@endauth + +{{-- ═══════════════════════════════════════════════════════════════ + SOSYAL ÖZELLİKLER JS: NicoNico + Tahminler + Watch Party + First-Watch + ═══════════════════════════════════════════════════════════════ --}} + +@endpush diff --git a/resources/views/frontend/premium/activate.blade.php b/resources/views/frontend/premium/activate.blade.php new file mode 100644 index 0000000..8a2359c --- /dev/null +++ b/resources/views/frontend/premium/activate.blade.php @@ -0,0 +1,129 @@ +@extends('frontend.layouts.app') +@section('title', 'Aktivasyon Kodu Gir — Animexe Premium') +@push('styles') + +@endpush + +@section('content') +
    +
    +
    +

    Aktivasyon Kodu Gir

    +

    Satın alma sonrası aldığın kodu girerek Premium üyeliğini hemen aktifleştir.

    + + @guest +
    + + Giriş Yap + +
    + Hesabın yoksa kayıt ol +
    +
    + @else +
    + @csrf +
    Aktivasyon Kodu
    + + @if($errors->has('code')) +
    {{ $errors->first('code') }}
    + @endif + +
    + +
    + Kod formatı: XXXX-XXXX-XXXX (büyük harf + tire)
    + Kodu doğru girdiğinden emin ol. Büyük/küçük harf fark etmez. +
    + @endguest + +
    veya
    + + Premium Planlarına Dön + +
    +
    + +@auth + +@endauth +@endsection diff --git a/resources/views/frontend/premium/plans.blade.php b/resources/views/frontend/premium/plans.blade.php new file mode 100644 index 0000000..f88a4b8 --- /dev/null +++ b/resources/views/frontend/premium/plans.blade.php @@ -0,0 +1,662 @@ +@extends('frontend.layouts.app') +@section('title', 'Animexe Premium — Anime Deneyimini Zirveye Taşı') +@section('meta_description', 'Animexe Premium ile reklamsız izle, özel kozmotikler kazan, erken erişim fırsatlarından yararlan.') +@push('styles') + +@endpush + +@section('content') +
    +
    +
    +
    +
    +
    Animexe Premium
    +

    + Anime deneyimini
    zirveye taşı +

    +

    + Reklamsız izleme, özel profil kozmetikleri, erken bölüm erişimi ve daha fazlası — hepsi tek bir Premium üyelikte. +

    +
    +
    İçerik
    +
    {{ count($featureGroups ?? []) > 0 ? array_sum(array_map(fn($g) => count($g['perks']), $featureGroups)) : '20+' }}Özel Özellik
    +
    7/24Destek
    +
    +
    +
    + +
    + + {{-- Aktivasyon başarı mesajı --}} + @if(session('activation_success')) +
    + +
    + Premium aktif! 🎉 {{ session('activation_success.plan') }} planına hoş geldin. + Üyeliğin {{ session('activation_success.expires_at') }} tarihine kadar geçerli. +
    +
    + @endif + + {{-- Mevcut premium kullanıcı banner --}} + @auth + @if(auth()->user()->isPremium()) +
    + +
    +
    Premium üyeliğin aktif 🎉
    +
    + Bitiş tarihi: {{ auth()->user()->premium_expires_at?->format('d MMMM Y') ?? auth()->user()->premium_expires_at?->format('d.m.Y') }} +  ·  Süre uzatmak için aşağıdan yeni bir plan satın alabilirsin. +
    +
    + +
    + @endif + @endauth + + {{-- Planlar --}} + @if(count($plans ?? []) > 0) +
    +

    Planını Seç

    +

    Tüm planlar aynı özellikleri içerir — süre ne kadar uzunsa o kadar tasarruf edersin.

    +
    +
    + @foreach($plans as $idx => $plan) + @php + $isFeatured = $plan->badge_label && $idx == 1 || ($plan->badge_label && str_contains(strtolower($plan->badge_label), 'popül')); + $isActive = auth()->check() && auth()->user()->subscriptions()->where('plan_id', $plan->id)->where('status','active')->exists(); + $perDay = $plan->duration_days > 0 ? round($plan->price / $plan->duration_days, 2) : 0; + $accentCls = $isFeatured ? 'featured' : ''; + @endphp +
    + @if($plan->badge_label) + @php + $badgeCls = match(true) { + str_contains(strtolower($plan->badge_label), 'değer') => 'gold', + str_contains(strtolower($plan->badge_label), 'popül') => '', + default => 'pink', + }; + @endphp +
    {{ $plan->badge_label }}
    + @endif + +
    {{ $plan->name }}
    +
    + {{ number_format($plan->price, 0, ',', '.') }} +
    +
    + @if($plan->duration_days >= 365) + {{ intdiv($plan->duration_days, 365) }} yıl + @elseif($plan->duration_days >= 30) + {{ intdiv($plan->duration_days, 30) }} ay + @else + {{ $plan->duration_days }} gün + @endif +  ·  Günde ≈ ₺{{ $perDay }} +
    + + @if($plan->trial_days) +
    İlk {{ $plan->trial_days }} gün ücretsiz!
    + @endif + +
    + + @if(count($plan->features ?? []) > 0) +
      + @foreach($plan->features as $feat) +
    • {{ $feat }}
    • + @endforeach +
    + @endif + + @if($isActive) + Mevcut Planın + @elseif($plan->purchase_link) + + Satın Al + + @else + Yakında + @endif +
    + @endforeach +
    + @endif + + {{-- Nasıl Çalışır --}} +
    +

    Nasıl Çalışır?

    +

    Üç adımda premium üyeliğini aktifleştir.

    +
    +
    +
    +
    +

    Paket Seç & Öde

    +

    Yukarıdaki planlardan birini seç, "Satın Al" butonuna tıkla ve güvenli ödeme sayfasına yönlendir. Ödemeyi tamamla.

    +
    +
    +
    +

    Aktivasyon Kodunu Al

    +

    Ödemen onaylandıktan sonra sana özel bir aktivasyon kodu gönderilir. Bu kodu saklaman yeterli.

    +
    +
    +
    +

    Kodu Gir, Keyfini Çıkar

    +

    Aşağıdaki kutuya aktivasyon kodunu gir. Anında aktif olur, tüm premium ayrıcalıklar hesabına yüklenir!

    +
    +
    + + {{-- Aktivasyon Kutusu --}} +
    +

    Aktivasyon Kodun Var mı?

    +

    Satın alma sonrası aldığın kodu girerek Premium üyeliğini hemen aktifleştir.

    + + @auth +
    + @csrf + + + @if($errors->has('code')) +
    {{ $errors->first('code') }}
    + @endif +
    + @else +
    + + +
    + + @endauth +
    + + {{-- Özellikler Showcase --}} + @if(count($featureGroups ?? []) > 0) +
    +

    Premium Ayrıcalıklar

    +

    Premium üyeliğinle birlikte gelen tüm özellikler.

    +
    +
    + @foreach($featureGroups as $group) + @php + $colors = [ + 'Profil' => ['bg'=>'rgba(184,77,255,.12)','color'=>'#b84dff','icon'=>'bi-person-circle'], + 'İzleme' => ['bg'=>'rgba(0,245,255,.12)','color'=>'#00f5ff','icon'=>'bi-play-circle-fill'], + 'Sosyal' => ['bg'=>'rgba(255,45,125,.12)','color'=>'#ff2d7d','icon'=>'bi-chat-heart-fill'], + 'Hesap' => ['bg'=>'rgba(255,214,10,.12)','color'=>'#ffd60a','icon'=>'bi-shield-fill-check'], + ]; + $c = $colors[$group['label']] ?? ['bg'=>'rgba(255,255,255,.08)','color'=>'#fff','icon'=>'bi-star-fill']; + @endphp +
    +
    +
    + +
    +

    {{ $group['label'] }}

    +
    +
    + @foreach(array_slice($group['perks'], 0, 6) as $perk) +
    +
    + +
    +
    +
    {{ $perk['name'] }}
    + @if(!empty($perk['desc']))
    {{ $perk['desc'] }}
    @endif +
    +
    + @endforeach +
    +
    + @endforeach +
    + @endif + + {{-- Kozmetik Preview --}} +
    +
    Profilini Kişiselleştir
    +
    Premium üyeler için özel görsel özelleştirmeler
    + +
    +
    +
    Profil Çerçeveleri
    +
    + @foreach(['neon','fire','galaxy','gold','sakura','rainbow','ice'] as $fr) +
    +
    {{ Str::upper(substr($fr,0,2)) }}
    +
    + @endforeach +
    +
    +
    +
    Yorum Arka Planları
    +
    + @foreach(['fire'=>'Ateş','aurora'=>'Aurora','stars'=>'Yıldız','sakura'=>'Sakura','neon'=>'Neon','galaxy'=>'Galaksi','ice'=>'Buz'] as $bg => $lbl) +
    {{ $lbl }}
    + @endforeach +
    +
    +
    + +
    +
    Kullanıcı Adı Renkleri
    +
    + @foreach([ + ['gradient'=>'linear-gradient(90deg,#00f5ff,#0080ff)','name'=>'Okyanus'], + ['gradient'=>'linear-gradient(90deg,#ff2d7d,#b84dff)','name'=>'Galaksi'], + ['gradient'=>'linear-gradient(90deg,#ffd700,#ff8c00)','name'=>'Altın'], + ['gradient'=>'linear-gradient(90deg,#32dc64,#00c8d4)','name'=>'Orman'], + ['gradient'=>'linear-gradient(90deg,#ff6b35,#ff2d7d)','name'=>'Ateş'], + ['gradient'=>'linear-gradient(90deg,#a8edff,#006fbf)','name'=>'Buz'], + ['gradient'=>'linear-gradient(90deg,#b84dff,#7c3aed)','name'=>'Mor'], + ] as $uc) + {{ $uc['name'] }} + @endforeach +
    +
    +
    + + {{-- FAQ --}} +
    +

    Sık Sorulan Sorular

    + @foreach([ + ['s'=>'Ödeme yaptıktan sonra nasıl aktifleştiririm?','c'=>'Ödemen onaylandıktan sonra sana aktivasyon kodu gönderilir. Bu sayfadaki "Aktivasyon Kodun Var mı?" kısmına kodu girip "Aktifleştir" butonuna tıklaman yeterli.'], + ['s'=>'Mevcut Premium üyeliğim varken başka bir kod girebilir miyim?','c'=>'Evet! Mevcut üyelik süren bitmeden kod girersen süreler üst üste eklenir (stack). Hiç süre kaybetmezsin.'], + ['s'=>'Aktivasyon kodum çalışmıyorsa ne yapmalıyım?','c'=>'Kodu büyük harflerle ve tire işaretleriyle birlikte girdiğinden emin ol (XXXX-XXXX-XXXX). Sorun devam ederse destek hattına başvur.'], + ['s'=>'Premium üyeliğimi iptal edebilir miyim?','c'=>'Premium üyeliğin süre tabanlıdır; otomatik yenileme yoktur. Süre dolunca normal hesabına dönersin.'], + ['s'=>'Premium özellikler hangi cihazlarda çalışır?','c'=>'Tüm cihazlarda (bilgisayar, tablet, telefon) çalışır. Hesabına giriş yaptığın her yerde premium ayrıcalıklarını kullanabilirsin.'], + ] as $faq) +
    + +
    {{ $faq['c'] }}
    +
    + @endforeach +
    + +
    {{-- /prm-wrap --}} +
    {{-- /pg --}} + + +@endsection diff --git a/resources/views/frontend/profile-settings.blade.php b/resources/views/frontend/profile-settings.blade.php new file mode 100644 index 0000000..2ed6fb3 --- /dev/null +++ b/resources/views/frontend/profile-settings.blade.php @@ -0,0 +1,904 @@ +@extends('frontend.layouts.app') +@section('title', 'Profil Ayarları — Animexe') + +@push('styles') + +@endpush + +@section('content') +
    + + + Profilime Dön + + + @if(session('success')) +
    + {{ session('success') }} +
    + @endif + @if($errors->any()) +
    + {{ $errors->first() }} +
    + @endif + + {{-- Banner --}} +
    + @csrf +
    + @if($user->banner_image) + Banner + @else +
    + +
    + @endif +
    + + Kapak Fotoğrafı Değiştir +
    +
    + +
    + + {{-- Avatar row --}} +
    +
    + @csrf +
    +
    + @if($user->avatar) + {{ $user->name }} + @else + {{ strtoupper(substr($user->name, 0, 1)) }} + @endif +
    +
    +
    + +
    +
    {{ $user->name }}
    +
    + + {{-- Ana bilgiler --}} +
    + @csrf + + +
    +
    Temel Bilgiler
    + +
    +
    +
    + + +
    +
    +
    +
    + +
    + @ + +
    +
    +
    +
    +
    + + +
    + {{ strlen($user->bio ?? '') }}/300 +
    +
    +
    +
    +
    + + {{-- Sosyal linkler --}} +
    +
    Sosyal Bağlantılar
    +
    +
    +
    + +
    + @ + +
    +
    +
    +
    +
    + +
    + @ + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + {{-- Profil rengi --}} +
    +
    Profil Rengi
    +

    + Profil sayfasındaki vurgu rengini seç. +

    +
    + @php + $colors = [ + '#00f5ff' => 'Siyan', + '#ff2d7d' => 'Pembe', + '#a371f7' => 'Mor', + '#3fb950' => 'Yeşil', + '#f0883e' => 'Turuncu', + '#ffd700' => 'Altın', + '#58a6ff' => 'Mavi', + '#ff6b6b' => 'Kırmızı', + '#e8d44d' => 'Sarı', + '#56d364' => 'Açık Yeşil', + ]; + $currentColor = old('profile_color', $user->profile_color ?? '#00f5ff'); + @endphp + @foreach($colors as $hex => $name) +
    + @endforeach + +
    +
    + Seçili: {{ $currentColor }} +
    +
    + + {{-- Gizlilik --}} +
    +
    Gizlilik
    +
    +
    +
    İzleme Listemi Göster
    +
    Diğer kullanıcılar listenizi görebilir
    +
    + +
    +
    +
    +
    İzleme Aktivitemi Göster
    +
    Izleme heatmap ve geçmişi herkese açık
    +
    + +
    +
    + + +
    + + {{-- Şifre değişikliği --}} +
    + @csrf +
    +
    Şifre Değiştir
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + + {{-- ── Premium Kozmetik Ayarları ────────────────────────────────── --}} + @if(auth()->user()->isPremium()) +
    + @csrf + @php $u = auth()->user(); @endphp + +
    +
    + Premium Kozmetik + Sadece premium üyelere özel +
    + + {{-- GIF Avatar --}} + @if($u->hasPerk('gif_avatar')) +
    + + + + {{-- Mevcut seçim önizleme --}} +
    +
    + GIF +
    +
    Seçili GIF
    +
    {{ $u->gif_avatar ?? '' }}
    +
    + +
    +
    + + {{-- Arama --}} +
    + + +
    + + {{-- Sonuç grid --}} +
    +
    +
    + @endif + + {{-- Yorum Arkaplanı --}} + @if($u->hasPerk('comment_bg')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::COMMENT_BACKGROUNDS as $key => $bg) + + @endforeach +
    +
    + @endif + + {{-- Kullanıcı Adı Rengi --}} + @if($u->hasPerk('username_color')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::USERNAME_COLORS as $key => $color) + + @endforeach +
    +
    + @endif + + {{-- Profil Çerçevesi --}} + @if($u->hasPerk('profile_frame')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::PROFILE_FRAMES as $key => $frame) + + @endforeach +
    +
    + @endif + + {{-- Özel Rozet/Unvan --}} + @if($u->hasPerk('profile_badge')) +
    + + +
    Max 32 karakter. Kullanıcı adının yanında görünür.
    +
    + @endif + + {{-- Profil Müziği --}} + @if($u->hasPerk('profile_music')) +
    + + +
    YouTube, SoundCloud veya direkt ses dosyası URL'si. Profilini ziyaret edenlere çalar.
    +
    + @endif + + {{-- Profil Arka Planı --}} + @if($u->hasPerk('profile_bg')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::PROFILE_BACKGROUNDS as $key => $bg) + @php [$c1,$c2] = explode(',', $bg['preview']); @endphp + + @endforeach +
    +
    + @endif + + {{-- Yorum Aura / Parıltı --}} + @if($u->hasPerk('comment_glow')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::COMMENT_GLOWS as $key => $glow) + + @endforeach +
    +
    + @endif + + {{-- Kullanıcı Adı Animasyonu --}} + @if($u->hasPerk('username_effect')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::USERNAME_EFFECTS as $key => $eff) + + @endforeach +
    +
    + @endif + + {{-- Yorum İmzası --}} + @if($u->hasPerk('comment_signature')) +
    + + +
    Max 100 karakter. Her yorumun altında küçük yazıyla görünür.
    +
    + @endif + + {{-- Sayfa Giriş Efekti --}} + @if($u->hasPerk('entry_effect')) +
    + +
    + + @foreach(\App\Services\PremiumFeatures::ENTRY_EFFECTS as $key => $eff) + + @endforeach +
    +
    + @endif + + {{-- Animasyonlu Banner --}} + @if($u->hasPerk('animated_banner')) +
    +
    +
    +
    Animasyonlu Profil Bannerı
    +
    Profil bannerın parıltılı parçacık efektiyle canlanır
    +
    + +
    +
    + @endif + + +
    +
    + @else + {{-- Premium değil — yükseltme banner'ı --}} +
    +
    +
    Premium Kozmetikler
    +
    + Animasyonlu yorum arkaplanları, renkli kullanıcı adı, profil çerçevesi ve daha fazlası için premium üye ol. +
    + + Premium'a Geç + +
    + @endif + +
    +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/frontend/profile.blade.php b/resources/views/frontend/profile.blade.php new file mode 100644 index 0000000..3ecfd21 --- /dev/null +++ b/resources/views/frontend/profile.blade.php @@ -0,0 +1,825 @@ +@extends('frontend.layouts.app') +@section('title', $user->name . ' — Profil — Animexe') +@push('styles') + +@endpush + +@section('content') + +@php $pColor = $user->profile_color ?? '#00f5ff'; @endphp + +{{-- Hero --}} +
    + + {{-- Banner --}} + @if($user->banner_image) +
    + +
    +
    + @else +
    + @endif + +
    + + {{-- Top row: avatar + info + edit button --}} +
    +
    + {{-- Avatar --}} +
    + @if($user->avatar) + {{ $user->name }} + @else + {{ mb_strtoupper(mb_substr($user->name,0,1)) }} + @endif +
    +
    +
    {{ $user->name }}
    + @if($user->username) +
    @php echo '@' . e($user->username); @endphp
    + @endif + @if($user->bio) +
    {{ $user->bio }}
    + @endif + + {{-- Sosyal linkler --}} + @if($user->twitter || $user->instagram || $user->discord || $user->website) +
    + @if($user->twitter) + + {{ $user->twitter }} + + @endif + @if($user->instagram) + + {{ $user->instagram }} + + @endif + @if($user->discord) + + {{ $user->discord }} + + @endif + @if($user->website) + + {{ parse_url($user->website, PHP_URL_HOST) }} + + @endif +
    + @endif + +
    + @if($user->role==='admin') + Admin + @elseif($user->role==='moderator') + Moderatör + @endif + @if($user->isPremium()) + Premium + @else + Üye + @endif + {{ $user->created_at->format('Y') }}'den beri +
    +
    +
    + + {{-- Ayarlar butonu — ayrı satırda, üstte sabit --}} + + Profili Düzenle + +
    + + {{-- Stats --}} +
    +
    + {{ $watchStats['episodes'] }} + Bölüm +
    +
    + {{ $watchStats['hours'] }} + Saat +
    +
    + {{ $watchStats['watchlist'] }} + Listede +
    +
    + {{ $commentCount }} + Yorum +
    + @if($allAchievements->count()) +
    + {{ $achievements->count() }} + Başarım +
    + @endif +
    + + {{-- Tab nav --}} +
    + @if($continueItems->count()) + + @endif + + + @if($allAchievements->count()) + + @endif + + + +
    +
    +
    + +{{-- Body --}} +
    + + {{-- DEVAM ET --}} + @if($continueItems->count()) +
    +
    Kaldığın Yerden Devam Et
    + +
    + @endif + + {{-- İZLEME LİSTESİ --}} +
    +
    + İzleme Listesi + @if(auth()->user()->hasPerk('watchlist_export')) + + @endif +
    + @if($watchlistItems->count()) + {{-- Filter buttons --}} +
    + + @foreach(\App\Models\Watchlist::STATUSES as $status => $label) + @if(isset($watchlistItems[$status]) && $watchlistItems[$status]->count()) + + @endif + @endforeach +
    + @foreach(\App\Models\Watchlist::STATUSES as $status => $label) + @if(isset($watchlistItems[$status]) && $watchlistItems[$status]->count()) +
    + @foreach($watchlistItems[$status] as $wl) + @if(!$wl->anime) @continue @endif + + @if($wl->anime->cover_image) + + @else +
    + @endif +
    {{ $wl->anime->title }}
    +
    + @endforeach +
    + @endif + @endforeach + @else +
    Liste henüz boş.
    + @endif +
    + + {{-- AKTİVİTE / HEATMAP --}} +
    +
    İzleme Aktivitesi — Son 1 Yıl
    + @php + $today = now()->startOfDay(); + $start = $today->copy()->subDays(364); + $gridStart= $start->copy()->startOfWeek(\Carbon\Carbon::MONDAY); + $weeks = []; + $cur = $gridStart->copy(); + while ($cur->lte($today)) { + $week = []; + for ($d = 0; $d < 7; $d++) { + $key = $cur->format('Y-m-d'); + $week[] = ['date'=>$key,'count'=>$heatmapRaw[$key]??0,'past'=>$cur->lte($today)&&$cur->gte($start)]; + $cur->addDay(); + } + $weeks[] = $week; + } + $maxCount = !empty($heatmapRaw) ? max($heatmapRaw) : 1; + $totalDays = collect($heatmapRaw)->filter()->count(); + $totalEpYear= array_sum($heatmapRaw); + @endphp +
    +
    +
    + @foreach($weeks as $week) +
    + @foreach($week as $cell) + @php + $alpha = 0; + $bg = 'rgba(255,255,255,.05)'; + if ($cell['past'] && $cell['count'] > 0) { + $intensity = min(1, $cell['count'] / max($maxCount,1)); + $alpha = 0.18 + $intensity * 0.82; + $bg = "rgba(0,245,255,{$alpha})"; + } elseif (!$cell['past']) { + $bg = 'transparent'; + } + @endphp +
    + @endforeach +
    + @endforeach +
    +
    +
    + Az +
    +
    +
    +
    +
    +
    +
    + Çok +
    +
    + {{ $totalDays }} aktif gün + {{ $totalEpYear }} bölüm izlendi +
    +
    +
    + + {{-- BAŞARIMLAR --}} + @if($allAchievements->count()) +
    + @php $earnedIds = $achievements->pluck('achievement_id')->toArray(); @endphp +
    Başarımlar {{ $achievements->count() }}/{{ $allAchievements->count() }}
    +
    + @foreach($allAchievements->sortByDesc(fn($a)=>in_array($a->id,$earnedIds)) as $ach) + @php $earned = in_array($ach->id, $earnedIds); @endphp +
    +
    + +
    +
    +
    {{ $ach->title }}
    +
    {{ $ach->description }}
    + @if($earned) + @php $ea = $achievements->firstWhere('achievement_id',$ach->id); @endphp +
    {{ $ea?->earned_at?->format('d.m.Y') }}
    + @endif +
    +
    + @endforeach +
    +
    + @endif + + {{-- NOTLAR --}} +
    +
    Bölüm Notlarım
    + @forelse($episodeNotes as $note) +
    +
    + @if($note->anime){{ $note->anime->title }}@endif + @if($note->episode){{ $note->episode->episode_number }}. Bölüm@endif + @if($note->timestamp_at) {{ $note->timestamp_label }}@endif + {{ $note->created_at->format('d.m.Y') }} +
    +
    {{ $note->content }}
    +
    + @empty +
    Henüz not almadın.
    İzlerken bölüm içindeki not ikonuna tıklayarak not alabilirsin.
    + @endforelse +
    + + {{-- YORUMLAR --}} + {{-- ── Keşfet Geçmişi ──────────────────────────────────────── --}} +
    +
    + Keşfet Geçmişi + @if($swipeHistory->count()) +
    + + + {{ $swipeHistory->where('direction','like')->count() }} beğeni + + + + {{ $swipeHistory->where('direction','skip')->count() }} geç + + + Devam et + +
    + @endif +
    + + @if($swipeHistory->isEmpty()) +
    + + Henüz hiç anime keşfetmedin. Hemen başla → +
    + @else + + {{-- Filter buttons --}} +
    + + + +
    + + + @endif +
    + +
    +
    Son Yorumlar
    + @if($recentComments->isEmpty()) +
    Henüz yorum yok.
    + @else + @foreach($recentComments as $c) +
    +
    +
    {{ mb_strtoupper(mb_substr($user->name,0,1)) }}
    + {{ $user->name }} + {{ $c->created_at->diffForHumans() }} +
    + @if($c->content)
    {{ $c->content }}
    @endif + @if($c->gif_url)@endif +
    + @endforeach + @endif +
    + +
    + +@endsection +@push('scripts') + +@endpush diff --git a/resources/views/frontend/public-profile.blade.php b/resources/views/frontend/public-profile.blade.php new file mode 100644 index 0000000..4a23fc2 --- /dev/null +++ b/resources/views/frontend/public-profile.blade.php @@ -0,0 +1,529 @@ +@extends('frontend.layouts.app') +@section('title', $user->name . ' — Profil — Animexe') +@section('meta_description', ($user->bio ? $user->bio . ' — ' : '') . $user->name . ' Animexe profili.') +@push('styles') + +@endpush + +@section('content') +@php + $pColor = $user->profile_color ?? '#00f5ff'; + $profBg = ($user->profile_bg && $user->hasPerk('profile_bg')) ? $user->profile_bg : null; + $animBanner = $user->hasPerk('animated_banner') && $user->animated_banner; + $watchRank = $user->watchRank(); + $musicUrl = ($user->profile_music_url && $user->hasPerk('profile_music')) ? $user->profile_music_url : null; +@endphp + +
    + @if($animBanner) + + @endif + @if($user->banner_image) +
    + +
    +
    + @else +
    + @endif + +
    +
    +
    + @php + $hasFrame = $user->profile_frame && $user->hasPerk('profile_frame'); + $avatarSrc = $user->gif_avatar && $user->hasPerk('gif_avatar') + ? $user->gif_avatar + : ($user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null); + @endphp +
    + @if($hasFrame) +
    + @endif +
    + @if($avatarSrc) + {{ $user->name }} + @else + {{ mb_strtoupper(mb_substr($user->name,0,1)) }} + @endif +
    +
    +
    + @php + $unColor = $user->username_color && $user->hasPerk('username_color') ? 'username-color-'.$user->username_color : ''; + $unEffect = $user->username_effect && $user->hasPerk('username_effect') ? 'username-effect-'.$user->username_effect : ''; + @endphp +
    username_effect==='glitch' && $unEffect) data-text="{{ $user->name }}" @endif>{{ $user->name }}
    + @if($user->username) +
    @php echo '@' . e($user->username); @endphp
    + @endif + @if($user->bio) +
    {{ $user->bio }}
    + @endif +
    + @if($user->role==='admin') + Admin + @elseif($user->role==='moderator') + Moderatör + @endif + @if($user->isPremium()) + Premium + @else + Üye + @endif + @if($user->profile_badge && $user->hasPerk('profile_badge')) + {{ $user->profile_badge }} + @endif + @if($watchRank) + + {{ $watchRank['label'] }} + + @endif + {{ $user->created_at->format('Y') }}'den beri +
    +
    +
    + +
    + @if($isOwnProfile) + + Profili Düzenle + + @elseif(auth()->check()) + +
    + @csrf + +
    + @endif +
    +
    + + @if($compatibility !== null) +
    + +
    +
    Anime zevk uyumu
    +
    +
    + %{{ $compatibility }} +
    + @endif + +
    +
    {{ $watchStats['episodes'] }}Bölüm
    +
    {{ $watchStats['hours'] }}Saat
    +
    {{ $followerCount }}Takipçi
    +
    {{ $followingCount }}Takip
    +
    {{ $commentCount }}Yorum
    +
    + +
    + + + +
    +
    +
    + +@if($musicUrl) +
    + +
    +
    {{ $user->name }} · Profil Müziği
    +
    +
    +
    +
    + + +
    +@endif + +
    + + {{-- İzleme Listesi --}} +
    + @php $watching = $watchlistItems->get('watching',[]); + $completed = $watchlistItems->get('completed',[]); + $planToWatch = $watchlistItems->get('plan_to_watch',[]); @endphp + + @if($watchlistItems->isEmpty()) +
    Henüz izleme listesi yok.
    + @else + @foreach(['watching'=>'İzliyor','completed'=>'Tamamladı','plan_to_watch'=>'İzleyecek'] as $status => $label) + @php $items = $watchlistItems->get($status, collect()); @endphp + @if($items->count()) +
    {{ $label }} ({{ $items->count() }})
    +
    + @foreach($items->take(12) as $wl) + @if($wl->anime) + + @if($wl->anime->cover_image) + {{ $wl->anime->title }} + @else +
    + @endif +
    {{ $wl->anime->title }}
    +
    + @endif + @endforeach +
    + @endif + @endforeach + @endif +
    + + {{-- Başarımlar --}} +
    + @if($achievements->isEmpty()) +
    Henüz başarım kazanılmamış.
    + @else +
    + @foreach($achievements as $ua) +
    +
    + +
    +
    +
    {{ $ua->achievement->name }}
    +
    {{ $ua->achievement->description }}
    +
    +
    + @endforeach +
    + @endif +
    + + {{-- Yorumlar --}} +
    + @if($recentComments->isEmpty()) +
    Henüz yorum yok.
    + @else + @foreach($recentComments as $c) + @php $ep = $c->episode; $anime = $ep?->anime ?? $c->anime; @endphp +
    +
    + @if($anime) + {{ $anime->title }} + @endif + {{ $c->created_at->diffForHumans() }} +
    +
    {{ Str::limit($c->content, 200) }}
    +
    + @endforeach + @endif +
    +
    + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/frontend/search.blade.php b/resources/views/frontend/search.blade.php new file mode 100644 index 0000000..c4b6a33 --- /dev/null +++ b/resources/views/frontend/search.blade.php @@ -0,0 +1,229 @@ +@extends('frontend.layouts.app') +@section('title', ($q ? '"'.$q.'" — Arama' : 'Tüm Animeler') . ' — Animexe') +@section('meta_description', $q ? '"' . $q . '" için anime arama sonuçları — Animexe\'de Türkçe anime izleyin.' : 'Animexe\'de tüm anime dizi ve filmlerini keşfedin. Tür, durum ve yıla göre filtreleyin.') +@section('robots', ($q || request()->has('genre') || request()->has('type') || request()->has('year') || $results->currentPage() > 1) ? 'noindex, follow' : 'index, follow') +@push('styles') + +@endpush + +@section('content') +
    +
    +

    {{ $q ? '"'.$q.'" için sonuçlar' : 'Tüm Animeler' }}

    +
    + {{-- Satır 1: Arama kutusu + Ara butonu --}} +
    +
    + + +
    + +
    + {{-- Satır 2: Filtreler + Sıralama --}} +
    + + + + + Sırala: + +
    +
    +
    +
    + +
    + @php + $sortLabels = [ + 'popular' => '🔥 En Popüler', + 'rating' => '⭐ En Yüksek Puan', + 'newest' => '🆕 En Yeni', + 'oldest' => '📅 En Eski', + 'az' => '🔤 A → Z', + 'za' => '🔤 Z → A', + 'personalized'=> '✨ Sana Özel', + ]; + @endphp +

    + Toplam {{ $results->total() }} anime bulundu + · {{ $sortLabels[$sort] ?? '' }} +

    + + @if($results->count()) + + + @if($results->hasPages()) +
    {{ $results->links('frontend.pagination') }}
    + @endif + @else +
    + +

    Sonuç bulunamadı. Farklı bir arama terimi deneyin.

    + Tümünü Göster +
    + @endif +
    +@endsection diff --git a/resources/views/frontend/tribunal/index.blade.php b/resources/views/frontend/tribunal/index.blade.php new file mode 100644 index 0000000..5c61621 --- /dev/null +++ b/resources/views/frontend/tribunal/index.blade.php @@ -0,0 +1,222 @@ +@extends('frontend.layouts.app') +@section('title', 'Anime Mahkemesi | Animexe') +@push('styles') + +@endpush +@section('content') +
    + +
    +

    ⚖️ Anime Mahkemesi

    +

    Tartışmalı kararlar, karakterler, sahneler — topluluk yargılıyor.

    + @auth +
    + + @endauth +
    + + + + {{ $tribunals->links('frontend.pagination') }} + +
    + +{{-- Dava Açma Modalı --}} +@auth + +@endauth + +@endsection +@push('scripts') + +@endpush diff --git a/resources/views/frontend/tribunal/show.blade.php b/resources/views/frontend/tribunal/show.blade.php new file mode 100644 index 0000000..9707843 --- /dev/null +++ b/resources/views/frontend/tribunal/show.blade.php @@ -0,0 +1,253 @@ +@extends('frontend.layouts.app') +@section('title', 'Mahkeme: ' . Str::limit($tribunal->question, 60) . ' | Animexe') +@php +// Sabit renk paleti — a,b,c,d,e,f +$palette = [ + 'a' => ['bg'=>'rgba(0,200,100,.08)', 'bg2'=>'rgba(0,200,100,.22)', 'border'=>'rgba(0,200,100,.25)', 'border2'=>'#00c864', 'text'=>'#00c864', 'bar'=>'#00c864'], + 'b' => ['bg'=>'rgba(255,60,60,.08)', 'bg2'=>'rgba(255,60,60,.22)', 'border'=>'rgba(255,60,60,.25)', 'border2'=>'#ff3c3c', 'text'=>'#ff3c3c', 'bar'=>'#ff3c3c'], + 'c' => ['bg'=>'rgba(59,130,246,.08)', 'bg2'=>'rgba(59,130,246,.22)', 'border'=>'rgba(59,130,246,.25)', 'border2'=>'#3b82f6', 'text'=>'#3b82f6', 'bar'=>'#3b82f6'], + 'd' => ['bg'=>'rgba(245,158,11,.08)', 'bg2'=>'rgba(245,158,11,.22)', 'border'=>'rgba(245,158,11,.25)', 'border2'=>'#f59e0b', 'text'=>'#f59e0b', 'bar'=>'#f59e0b'], + 'e' => ['bg'=>'rgba(167,139,250,.08)','bg2'=>'rgba(167,139,250,.22)','border'=>'rgba(167,139,250,.25)','border2'=>'#a78bfa','text'=>'#a78bfa','bar'=>'#a78bfa'], + 'f' => ['bg'=>'rgba(236,72,153,.08)', 'bg2'=>'rgba(236,72,153,.22)', 'border'=>'rgba(236,72,153,.25)', 'border2'=>'#ec4899','text'=>'#ec4899','bar'=>'#ec4899'], +]; +$cols = count($allSides) <= 2 ? '1fr 1fr' : (count($allSides) <= 3 ? '1fr 1fr 1fr' : 'repeat(auto-fit,minmax(140px,1fr))'); +@endphp +@push('styles') + +@endpush +@section('content') +
    + + + {{ $tribunal->anime->title }} + + +
    + {{ $tribunal->status === 'open' ? '🟢 Dava Açık' : '🔴 Dava Kapalı' }} + @if($tribunal->closes_at && $tribunal->status === 'open') + · {{ $tribunal->closes_at->diffForHumans() }} kapanıyor + @endif +
    + +
    {{ $tribunal->question }}
    + + {{-- Oy sistemi --}} +
    +
    + @foreach($allSides as $key => $label) + @php $c = $palette[$key] ?? $palette['a']; @endphp + + @endforeach +
    + + {{-- Çok taraflı bar --}} +
    + @foreach($allSides as $key => $label) + @php $c = $palette[$key] ?? $palette['a']; $pct = $total > 0 ? round(($voteCounts[$key]??0)/$total*100) : 0; @endphp +
    +
    {{ $label }}
    +
    +
    +
    +
    {{ $pct }}%
    +
    + @endforeach +
    + +
    + Toplam {{ $total }} oy + @auth @if($myVote) · Oyunuz: {{ $allSides[$myVote] ?? $myVote }} @endif @endauth +
    +
    + + {{-- Argümanlar --}} +
    +
    Argümanlar
    +
    + @forelse($arguments as $arg) + @php $c2 = $palette[$arg['side']] ?? $palette['a']; @endphp +
    + {{ $allSides[$arg['side']] ?? $arg['side'] }} +
    {{ $arg['body'] }}
    +
    + {{ $arg['name'] ?? $arg['username'] }} + {{ $arg['created_at'] }} + +
    +
    + @empty +
    + Henüz argüman girilmemiş. İlk sen gir! +
    + @endforelse +
    +
    + + {{-- Argüman formu --}} + @auth + @if(!$myArgument && $tribunal->status === 'open') +
    +
    ⚔️ Senin Argümanın — Hangi taraftasın?
    +
    + @foreach($allSides as $key => $label) + @php $c3 = $palette[$key] ?? $palette['a']; @endphp + + @endforeach +
    + + + +
    + @elseif($myArgument) +
    + ✅ Argümanınızı gönderdiniz. +
    + @endif + @else +
    + Giriş yap — argüman girmek için +
    + @endauth + +
    +@endsection +@push('scripts') + +@endpush diff --git a/resources/views/frontend/watch-party.blade.php b/resources/views/frontend/watch-party.blade.php new file mode 100644 index 0000000..43cd725 --- /dev/null +++ b/resources/views/frontend/watch-party.blade.php @@ -0,0 +1,274 @@ +@extends('frontend.layouts.app') +@section('title', 'Watch Party — ' . $party->episode->anime->title . ' | Animexe') +@push('styles') + +@endpush +@section('content') +
    + + {{-- ── Ana video alanı ─────────────────────────────────────────── --}} +
    + + {{-- Video --}} +
    + @if($party->episode->m3u8_url || $party->episode->video_url) + + @else +
    +
    Video yüklenemedi
    +
    + @endif +
    + + {{-- Host kontrol barı --}} +
    +
    +
    Oda Kodu
    +
    {{ $party->room_code }}
    +
    +
    +
    + Ev Sahibi: {{ $party->host->name ?? '—' }} + @auth @if(auth()->id() === $party->host_user_id) + Sensin + @endif @endauth +
    +
    0 üye aktif
    +
    + @auth @if(auth()->id() === $party->host_user_id) + + + @endif @endauth + ⏱ bekleniyor... + + Tam oynatıcı + +
    + +
    + + {{-- ── Sağ panel: Üyeler + Chat ─────────────────────────────────── --}} +
    + + {{-- Bölüm bilgisi --}} +
    + @if($party->episode->anime->coverUrl) + + @endif +
    +
    {{ $party->episode->anime->title }}
    +
    S{{ $party->episode->season->season_number ?? 1 }}E{{ $party->episode->episode_number }} + {{ $party->episode->title ? '— ' . $party->episode->title : '' }}
    +
    +
    + + {{-- Aktif üyeler --}} +
    +
    Aktif Üyeler
    +
    +
    + + {{-- Chat --}} +
    + + {{-- Chat input --}} + @auth +
    + + +
    + @else +
    + Giriş yap — sohbete katıl +
    + @endauth + +
    + +
    +@endsection +@push('scripts') + + +@endpush diff --git a/resources/views/sitemap.blade.php b/resources/views/sitemap.blade.php new file mode 100644 index 0000000..def4db9 --- /dev/null +++ b/resources/views/sitemap.blade.php @@ -0,0 +1,45 @@ +'; ?> + + + {{-- Statik sayfalar --}} + @foreach($staticPages as $page) + + {{ $domain }}{{ $page['loc'] }} + {{ $page['priority'] }} + {{ $page['changefreq'] }} + {{ now()->toAtomString() }} + + @endforeach + + {{-- Türler --}} + @foreach($genres as $genre) + + {{ $domain }}/genre/{{ $genre->slug }} + 0.7 + weekly + @if($genre->updated_at) + {{ $genre->updated_at->toAtomString() }} + @endif + + @endforeach + + {{-- Animeler --}} + @foreach($animes as $anime) + + {{ $domain }}/anime/{{ $anime->slug }} + {{ $anime->rating >= 8 ? '0.9' : ($anime->rating >= 6 ? '0.8' : '0.6') }} + weekly + @if($anime->updated_at) + {{ $anime->updated_at->toAtomString() }} + @endif + @if($anime->cover_image_url) + + {{ $anime->cover_image_url }} + {{ htmlspecialchars($anime->title) }} + + @endif + + @endforeach + + diff --git a/resources/views/sitemap_index.blade.php b/resources/views/sitemap_index.blade.php new file mode 100644 index 0000000..075298d --- /dev/null +++ b/resources/views/sitemap_index.blade.php @@ -0,0 +1,19 @@ +'; ?> + + + {{ $domain }}/sitemap-main.xml + {{ now()->toAtomString() }} + + + {{ $domain }}/sitemap-animes.xml + {{ now()->toAtomString() }} + + + {{ $domain }}/sitemap-videos.xml + {{ now()->toAtomString() }} + + + {{ $domain }}/sitemap-blog.xml + {{ now()->toAtomString() }} + + diff --git a/resources/views/sitemaps/animes.blade.php b/resources/views/sitemaps/animes.blade.php new file mode 100644 index 0000000..4761b64 --- /dev/null +++ b/resources/views/sitemaps/animes.blade.php @@ -0,0 +1,21 @@ +'; ?> + + + @foreach($animes as $anime) + + {{ $domain }}/anime/{{ $anime->slug }} + {{ $anime->rating >= 8 ? '0.9' : ($anime->rating >= 6 ? '0.8' : '0.7') }} + weekly + {{ $anime->updated_at?->toAtomString() ?? now()->toAtomString() }} + @if($anime->cover_image_url) + + {{ $anime->cover_image_url }} + {{ htmlspecialchars($anime->title . ' İzle - Türkçe Altyazılı') }} + {{ htmlspecialchars($anime->title) }} anime Türkçe altyazılı ücretsiz izle — Animexe + + @endif + + @endforeach + + diff --git a/resources/views/sitemaps/blog.blade.php b/resources/views/sitemaps/blog.blade.php new file mode 100644 index 0000000..19a42e5 --- /dev/null +++ b/resources/views/sitemaps/blog.blade.php @@ -0,0 +1,32 @@ +'; ?> + + + @foreach($posts as $post) + + {{ $domain }}/blog/{{ $post->slug }} + 0.75 + monthly + {{ $post->updated_at?->toAtomString() ?? now()->toAtomString() }} + @if($post->cover_image) + + {{ $post->cover_image }} + {{ htmlspecialchars($post->title) }} + + @endif + @if($post->published_at && $post->published_at->gt(now()->subDays(2))) + + + Animexe Blog + tr + + {{ $post->published_at->toAtomString() }} + {{ htmlspecialchars($post->title) }} + @if($post->excerpt){{ htmlspecialchars(\Illuminate\Support\Str::limit($post->excerpt, 200)) }}@endif + + @endif + + @endforeach + + diff --git a/resources/views/sitemaps/main.blade.php b/resources/views/sitemaps/main.blade.php new file mode 100644 index 0000000..ecde0db --- /dev/null +++ b/resources/views/sitemaps/main.blade.php @@ -0,0 +1,22 @@ +'; ?> + + + @foreach($staticPages as $page) + + {{ $domain }}{{ $page['loc'] }} + {{ $page['priority'] }} + {{ $page['changefreq'] }} + {{ now()->toAtomString() }} + + @endforeach + + @foreach($genres as $genre) + + {{ $domain }}/genre/{{ $genre->slug }} + 0.7 + weekly + {{ $genre->updated_at?->toAtomString() ?? now()->toAtomString() }} + + @endforeach + + diff --git a/resources/views/sitemaps/videos.blade.php b/resources/views/sitemaps/videos.blade.php new file mode 100644 index 0000000..2b1d387 --- /dev/null +++ b/resources/views/sitemaps/videos.blade.php @@ -0,0 +1,27 @@ +'; ?> + + + @foreach($animes as $anime) + + {{ $domain }}/anime/{{ $anime->slug }} + + {{ $anime->cover_image_url ?? $domain . '/logo.jpg' }} + {{ htmlspecialchars($anime->title . ' İzle - Türkçe Altyazılı') }} + {{ htmlspecialchars(\Illuminate\Support\Str::limit(strip_tags($anime->description ?? $anime->title . ' Türkçe altyazılı ücretsiz izle — Animexe'), 200)) }} + {{ $anime->first_ep_watch_url }} + @foreach($anime->episodes->take(3) as $ep) + @php $sNum = optional($ep->season)->season_number ?? 1; @endphp + {{ htmlspecialchars($anime->title . ' ' . $ep->episode_number . '. Bölüm İzle') }} + @endforeach + anime izle + türkçe anime + {{ htmlspecialchars($anime->title) }} izle + yes + no + no + + + @endforeach + + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..b7355d7 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
    + @if (Route::has('login')) + + @endif +
    +
    +
    +
    +

    Let's get started

    +

    Laravel has an incredibly rich ecosystem.
    We suggest starting with the following.

    + + +
    +
    + {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
    +
    +
    +
    + + @if (Route::has('login')) + + @endif + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..dee735d --- /dev/null +++ b/routes/api.php @@ -0,0 +1,223 @@ +prefix('import')->group(function () { + Route::get('test', [ImportApiController::class, 'test']); + Route::get('stats', [ImportApiController::class, 'stats']); + Route::get('settings', [ImportApiController::class, 'settings']); + Route::get('next-job', [ImportApiController::class, 'nextJob']); + Route::post('jobs', [ImportApiController::class, 'createJob']); // AUTO-IMPORT + Route::get('imported-ids', [ImportApiController::class, 'importedIds']); + Route::get('imported-titles', [ImportApiController::class, 'importedTitles']); + Route::get('ongoing', [ImportApiController::class, 'ongoingAnimes']); + Route::get('all-animes', [ImportApiController::class, 'allAniziumAnimes']); + Route::get('jobs/{job}', [ImportApiController::class, 'getJob']); + Route::post('jobs/{job}/status', [ImportApiController::class, 'updateStatus']); + Route::post('jobs/{job}/episode', [ImportApiController::class, 'saveEpisode']); + Route::post('jobs/{job}/subtitle', [ImportApiController::class, 'saveSubtitle']); + Route::get('jobs/{job}/done-episodes', [ImportApiController::class, 'doneEpisodes']); + + // Altyazı yenileme + Route::get('anizium-episodes', [ImportApiController::class, 'aniziumEpisodes']); + Route::post('subtitle-direct', [ImportApiController::class, 'saveSubtitleDirect']); + + // Sağlık kontrolü + deaktivasyon + Route::get('anizium-health', [ImportApiController::class, 'aniziumHealthData']); + Route::post('deactivate-anime', [ImportApiController::class, 'deactivateAnime']); + + // Animecix-specific + Route::get('animecix/pending', [ImportApiController::class, 'animecixPendingJobs']); + Route::post('episodes/{episode}/video-sources', [ImportApiController::class, 'saveVideoSources']); + + // Çapraz kaynak desteği + Route::get('anime/lookup', [ImportApiController::class, 'animeLookup']); + Route::get('published-animes-with-jobs', [ImportApiController::class, 'publishedAnimesWithJobs']); +}); + +// ── Mobile App API ──────────────────────────────────────────────────────────── + +// App status (public — called on startup to check maintenance/force-update) +Route::get('app-status', function () { + $minVersion = \App\Models\Setting::get('mobile_min_version', '1.0.0'); + $currentVersion = \App\Models\Setting::get('mobile_current_version', '1.0.0'); + $maintenance = \App\Models\Setting::get('mobile_maintenance_mode', '0') === '1'; + $maintMsg = \App\Models\Setting::get('mobile_maintenance_message', 'Uygulama şu anda bakımda.'); + $apkUrl = \App\Models\Setting::get('mobile_apk_url', ''); + $forceMsg = \App\Models\Setting::get('mobile_force_update_msg', 'Lütfen uygulamayı güncelleyin.'); + + // Determine force_update by comparing appVersion (sent as ?v=x.y.z) with minVersion + $appVersion = request('v', '0.0.0'); + $forceUpdate = version_compare($appVersion, $minVersion, '<'); + + return response()->json([ + 'maintenance' => $maintenance, + 'maintenance_message' => $maintMsg, + 'min_version' => $minVersion, + 'current_version' => $currentVersion, + 'apk_url' => $apkUrl ?: null, + 'force_update' => $forceUpdate, + 'force_update_message'=> $forceMsg, + ]); +}); + +// Auth (public) +Route::prefix('auth')->group(function () { + Route::post('register', [AuthApiController::class, 'register']); + Route::post('login', [AuthApiController::class, 'login']); +}); + +// Public content +Route::get('home', [AnimeApiController::class, 'home']); +Route::get('animes', [AnimeApiController::class, 'index']); +Route::get('animes/{slug}', [AnimeApiController::class, 'show']); +Route::get('genres', [AnimeApiController::class, 'genres']); +Route::get('genres/{slug}', [AnimeApiController::class, 'genre']); +Route::get('watch/{slug}/{season}/{episode}', [AnimeApiController::class, 'watch']); + +// AniSkip (public — no auth needed, does Jikan+AniSkip lookup with caching) +Route::get('aniskip/{slug}/{season}/{episode}', [AnimeApiController::class, 'aniSkip']); + +// Skip segments (public — no auth needed) +Route::get('episodes/{episode}/skip-segments', [AnimeApiController::class, 'skipSegments']); +Route::post('episodes/{episode}/skip-event', [AnimeApiController::class, 'recordSkipEvent']) + ->middleware('throttle:60,1'); // max 60 events per minute per IP + +// HEVC flag — player tarafından HEVC hatası alınan kaynakları işaretler +Route::post('sources/flag-hevc', [AnimeApiController::class, 'flagHevc']) + ->middleware('throttle:30,1'); + +// Reklam istatistikleri — path bilerek nötr ('ads' değil), adblocker /api/ads/ engelliyor +Route::post('slot/{ad}/view', [\App\Http\Controllers\Api\AdApiController::class, 'impression']) + ->middleware('throttle:30,1'); +Route::post('slot/{ad}/hit', [\App\Http\Controllers\Api\AdApiController::class, 'click']) + ->middleware('throttle:30,1'); + +// Comments (public read, auth write) +Route::get('comments', [CommentApiController::class, 'index']); + +// NicoNico timestamp comments (public read) +Route::get('episodes/{episode}/nico-comments', [SocialApiController::class, 'timestampComments']); + +// Predictions (public read) +Route::get('episodes/{episode}/predictions', [SocialApiController::class, 'predictions']); + +// Spoiler boxes (public read) +Route::get('episodes/{episode}/spoiler-boxes', [SocialApiController::class, 'spoilerBoxes']); + +// Tribunal (public read) +Route::get('tribunals', [TribunalApiController::class, 'index']); +Route::get('tribunals/{tribunal}', [TribunalApiController::class, 'show']); + +// Watch party info (public) +Route::get('party/{roomCode}', [SocialApiController::class, 'partyInfo']); + +// Mood engine (public) +Route::post('mood/recommend', [SocialApiController::class, 'moodRecommend']); + +// Anime requests (public list) +Route::get('anime-request', [UserApiController::class, 'requestIndex']); + +// Notifications count (optional auth) +Route::get('notifications/count', [UserApiController::class, 'notificationsCount']) + ->middleware('auth:sanctum'); + +// ── Auth required ───────────────────────────────────────────────────────────── +Route::middleware('auth:sanctum')->group(function () { + // Auth + Route::post('auth/logout', [AuthApiController::class, 'logout']); + Route::get('auth/me', [AuthApiController::class, 'me']); + Route::post('auth/profile', [AuthApiController::class, 'updateProfile']); + Route::post('auth/fcm-token', [AuthApiController::class, 'saveFcmToken']); + + // Profile stats + Route::get('profile/stats', [UserApiController::class, 'profileStats']); + + // Watchlist + Route::get('watchlist', [UserApiController::class, 'watchlist']); + Route::post('watchlist/{anime}/toggle', [UserApiController::class, 'watchlistToggle']); + + // Continue watching + Route::post('continue-watching', [UserApiController::class, 'continueWatchingUpdate']); + + // Rate & Follow + Route::post('anime/{anime}/rate', [UserApiController::class, 'animeRate']); + Route::post('anime/{anime:slug}/follow', [UserApiController::class, 'followToggle']); + + // Notifications + Route::get('notifications', [UserApiController::class, 'notifications']); + + // Achievements + Route::get('achievements', [UserApiController::class, 'achievements']); + + // Episode notes + Route::post('episode/{episode}/note', [UserApiController::class, 'noteStore']); + Route::delete('notes/{note}', [UserApiController::class, 'noteDelete']); + Route::get('episode/{episode}/notes', [UserApiController::class, 'episodeNotesList']); + + // Comments + Route::post('comments', [CommentApiController::class, 'store']); + Route::post('comments/{comment}/like', [CommentApiController::class, 'like']); + + // Anime requests + Route::post('anime-request', [UserApiController::class, 'requestStore']); + Route::post('anime-request/{animeRequest}/vote', [UserApiController::class, 'requestVote']); + + // AI + Route::post('ai/chat', [AiApiController::class, 'chat']); + Route::post('ai/recommend', [AiApiController::class, 'recommend']); + Route::post('ai/similar', [AiApiController::class, 'similar']); + Route::post('ai/search', [AiApiController::class, 'search']); + + // NicoNico (auth write) + Route::post('episodes/{episode}/nico-comments', [SocialApiController::class, 'timestampCommentStore']); + + // Predictions (auth write) + Route::post('episodes/{episode}/predictions', [SocialApiController::class, 'predictionStore']); + Route::post('predictions/{prediction}/vote', [SocialApiController::class, 'predictionVote']); + + // Spoiler boxes (auth write) + Route::post('episodes/{episode}/spoiler-boxes', [SocialApiController::class, 'spoilerBoxStore']); + Route::post('spoiler-boxes/{box}/like', [SocialApiController::class, 'spoilerBoxLike']); + + // Time capsule + Route::get('capsules', [SocialApiController::class, 'capsuleIndex']); + Route::post('capsules', [SocialApiController::class, 'capsuleStore']); + Route::post('capsules/{capsule}/open', [SocialApiController::class, 'capsuleOpen']); + + // Tribunal (auth write) + Route::post('tribunals', [TribunalApiController::class, 'store']); + Route::post('tribunals/{tribunal}/vote', [TribunalApiController::class, 'vote']); + Route::post('tribunals/{tribunal}/argue', [TribunalApiController::class, 'argue']); + Route::post('tribunal-arguments/{argument}/vote', [TribunalApiController::class, 'argVote']); + + // Watch party + Route::post('party/create', [SocialApiController::class, 'partyCreate']); + Route::post('party/{roomCode}/join', [SocialApiController::class, 'partyJoin']); + Route::post('party/{roomCode}/sync', [SocialApiController::class, 'partySync']); + Route::post('party/{roomCode}/leave', [SocialApiController::class, 'partyLeave']); + + // User follow + Route::post('users/{user}/follow', [SocialApiController::class, 'followToggle']); + + // Plans + Route::get('plans', [PlanApiController::class, 'index']); + + // ── Mesajlar (Mobile Chat) — sabit rotalar önce gelmeli + Route::get('messages', [MessageApiController::class, 'conversations']); + Route::post('messages/start/{user}', [MessageApiController::class, 'startConversation']); + Route::get('messages/{conversation}', [MessageApiController::class, 'show']); + Route::post('messages/{conversation}', [MessageApiController::class, 'send']); + Route::get('messages/{conversation}/poll', [MessageApiController::class, 'poll']); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..fe4651e --- /dev/null +++ b/routes/console.php @@ -0,0 +1,33 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); + +// AI blog: günde 3 kez (09:00, 15:00, 21:00) — her seferinde 1 yeni yazı +Schedule::command('animexe:generate-blogs --count=1') + ->cron('0 9,15,21 * * *') + ->withoutOverlapping() + ->runInBackground(); + +// AI anime meta doldurma: her gece 02:00 — eksik tüm animeleri işle, bititiğinde dur +Schedule::command('animexe:fill-anime-meta') + ->dailyAt('02:00') + ->withoutOverlapping() + ->runInBackground(); + +// Her saat başı süresi dolan mahkemeleri kapat +Schedule::command('tribunals:close') + ->hourly() + ->withoutOverlapping(); + +// Her gece 02:30 — kapak/banneri eksik animelere AniList'ten resim çek +Schedule::command('anime:fetch-images --missing --limit=100') + ->dailyAt('02:30') + ->withoutOverlapping() + ->runInBackground(); + diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..93acd18 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,742 @@ +where('path', '.*') + ->name('media.show'); + +// ── Admin Login ────────────────────────────────────────────────────────────── +Route::prefix('admin')->name('admin.')->group(function () { + Route::get('login', [Admin\AuthController::class, 'showLogin'])->name('login'); + Route::post('login', [Admin\AuthController::class, 'login'])->name('login.post'); + Route::post('logout', [Admin\AuthController::class, 'logout'])->name('logout'); +}); + +// ── Admin Panel ────────────────────────────────────────────────────────────── +// admin.access = auth kontrolü + admin veya moderatör rolü (permission yok) +// admin = sadece admin (veya moderatör + belirtilen permission) +Route::prefix('admin')->name('admin.')->middleware(['admin.access'])->group(function () { + + // Dashboard — tüm moderatörlere açık + Route::get('/', [Admin\DashboardController::class, 'index'])->name('dashboard'); + + // Anime + Route::resource('animes', Admin\AnimeController::class)->middleware(['admin:animes.view']); + Route::post('animes/{anime}/permissions', [Admin\AnimeController::class, 'updatePermissions'])->name('animes.permissions')->middleware(['admin:animes.edit']); + Route::post('animes/{anime}/fetch-mal-seasons', [Admin\AnimeController::class, 'fetchMalSeasons'])->name('animes.fetch-mal-seasons')->middleware(['admin:animes.edit']); + Route::post('animes/{anime}/fetch-mal', [Admin\AnimeController::class, 'fetchMalSingle'])->name('animes.fetch-mal')->middleware(['admin:animes.edit']); + Route::post('animes/bulk-find-mal', [Admin\AnimeController::class, 'bulkFindMal'])->name('animes.bulk-find-mal')->middleware(['admin:animes.edit']); + Route::post('animes/bulk-destroy', [Admin\AnimeController::class, 'bulkDestroy'])->name('animes.bulk-destroy')->middleware(['admin:animes.edit']); + Route::post('animes/destroy-zero-episodes', [Admin\AnimeController::class, 'destroyZeroEpisodes'])->name('animes.destroy-zero-episodes')->middleware(['admin']); + + // Sezon + Route::resource('animes.seasons', Admin\SeasonController::class)->shallow()->middleware(['admin:episodes.view']); + + // Bölüm + Route::post('episodes/bulk-intro', [Admin\EpisodeController::class, 'bulkIntro'])->name('episodes.bulk-intro')->middleware(['admin:episodes.edit']); + Route::post('episodes/bulk-destroy', [Admin\EpisodeController::class, 'bulkDestroy'])->name('episodes.bulk-destroy')->middleware(['admin:episodes.edit']); + Route::post('episodes/{episode}/scan-hevc', [Admin\EpisodeController::class, 'scanHevc'])->name('episodes.scan-hevc')->middleware(['admin:episodes.edit']); + Route::resource('episodes', Admin\EpisodeController::class)->middleware(['admin:episodes.view']); + + // Reklamlar + Route::post('ads-settings', [Admin\AdController::class, 'saveSettings'])->name('ads.settings')->middleware(['admin']); + Route::post('ads/{ad}/toggle', [Admin\AdController::class, 'toggle'])->name('ads.toggle')->middleware(['admin']); + Route::resource('ads', Admin\AdController::class)->except(['create', 'show'])->middleware(['admin']); + + // Kullanıcı + Route::resource('users', Admin\UserController::class)->middleware(['admin:users.view']); + Route::post('users/{user}/ban', [Admin\UserController::class, 'ban'])->name('users.ban')->middleware(['admin:users.ban']); + Route::post('users/{user}/unban', [Admin\UserController::class, 'unban'])->name('users.unban')->middleware(['admin:users.ban']); + Route::post('users/{user}/give-premium', [Admin\UserController::class, 'givePremium'])->name('users.give-premium')->middleware(['admin:users.premium']); + Route::post('users/{user}/remove-premium', [Admin\UserController::class, 'removePremium'])->name('users.remove-premium')->middleware(['admin:users.premium']); + + // Yorum + Route::resource('comments', Admin\CommentController::class)->only(['index', 'show', 'destroy'])->middleware(['admin:comments.view']); + Route::post('comments/{comment}/approve', [Admin\CommentController::class, 'approve'])->name('comments.approve')->middleware(['admin:comments.approve']); + Route::post('comments/{comment}/reject', [Admin\CommentController::class, 'reject'])->name('comments.reject')->middleware(['admin:comments.approve']); + Route::post('comments/{comment}/pin', [Admin\CommentController::class, 'pin'])->name('comments.pin')->middleware(['admin:comments.pin']); + Route::post('comments/{comment}/reply', [Admin\CommentController::class, 'reply'])->name('comments.reply')->middleware(['admin:comments.approve']); + + // Üyelik planları — sadece admin + Route::resource('plans', Admin\PlanController::class)->middleware(['admin']); + + // Abonelikler — sadece admin + Route::resource('subscriptions', Admin\SubscriptionController::class)->only(['index', 'show', 'store', 'destroy'])->middleware(['admin']); + + // Aktivasyon kodları — sadece admin + Route::prefix('activation-codes')->name('activation-codes.')->middleware(['admin'])->group(function () { + Route::get('/', [Admin\ActivationCodeController::class, 'index'])->name('index'); + Route::post('/generate', [Admin\ActivationCodeController::class, 'generate'])->name('generate'); + Route::get('/export', [Admin\ActivationCodeController::class, 'export'])->name('export'); + Route::delete('/{activationCode}', [Admin\ActivationCodeController::class, 'destroy'])->name('destroy'); + Route::post('/destroy-batch', [Admin\ActivationCodeController::class, 'destroyBatch'])->name('destroy-batch'); + Route::post('/destroy-selected', [Admin\ActivationCodeController::class, 'destroySelected'])->name('destroy-selected'); + }); + + // Türler + Route::resource('genres', Admin\GenreController::class)->middleware(['admin:genres.manage']); + + // Banner + Route::resource('banners', Admin\BannerController::class)->middleware(['admin:banners.manage']); + + // Sezon JSON (AJAX) + Route::get('animes/{anime}/seasons-json', function (\App\Models\Anime $anime) { + return response()->json($anime->seasons()->select('id', 'season_number')->get()); + })->name('animes.seasons-json')->middleware(['admin:animes.view']); + + // Global İzin Ayarları — sadece admin + Route::get('permissions', [Admin\PermissionController::class, 'index'])->name('permissions.index')->middleware(['admin']); + Route::post('permissions', [Admin\PermissionController::class, 'update'])->name('permissions.update')->middleware(['admin']); + + // Ayarlar — sadece admin + Route::get('settings', [Admin\SettingController::class, 'index'])->name('settings.index')->middleware(['admin']); + Route::post('settings', [Admin\SettingController::class, 'update'])->name('settings.update')->middleware(['admin']); + Route::post('settings/intro-upload', [Admin\SettingController::class, 'uploadIntro'])->name('settings.intro.upload')->middleware(['admin']); + Route::post('settings/favicon-upload', [Admin\SettingController::class, 'uploadFavicon'])->name('settings.favicon.upload')->middleware(['admin']); + Route::post('settings/mail-test', [Admin\SettingController::class, 'testMail'])->name('settings.mail.test')->middleware(['admin']); + + // Trend Yönetimi — sadece admin + Route::get('trending', [Admin\TrendingController::class, 'index'])->name('trending.index')->middleware(['admin']); + Route::post('trending/{anime}/toggle', [Admin\TrendingController::class, 'toggle'])->name('trending.toggle')->middleware(['admin']); + Route::post('trending/{anime}/move', [Admin\TrendingController::class, 'move'])->name('trending.move')->middleware(['admin']); + Route::post('trending/reorder', [Admin\TrendingController::class, 'reorder'])->name('trending.reorder')->middleware(['admin']); + Route::get('trending/search', [Admin\TrendingController::class, 'search'])->name('trending.search')->middleware(['admin']); + Route::post('trending/compute-scores', [Admin\TrendingController::class, 'computeScores'])->name('trending.compute-scores')->middleware(['admin']); + + // AI — sadece admin + Route::post('ai/generate', [Admin\AiController::class, 'generate'])->name('ai.generate')->middleware(['admin']); + Route::post('ai/anime-meta', [Admin\AiController::class, 'animeMeta'])->name('ai.anime-meta')->middleware(['admin']); + Route::get('ai/descriptions', [Admin\AiController::class, 'descriptionsPage'])->name('ai.descriptions')->middleware(['admin']); + Route::post('ai/episode-ids', [Admin\AiController::class, 'episodeIds'])->name('ai.episode-ids')->middleware(['admin']); + Route::post('ai/fill-one', [Admin\AiController::class, 'fillOne'])->name('ai.fill-one')->middleware(['admin']); + Route::get('ai/anime-meta-bulk', [Admin\AiController::class, 'animeMetaPage'])->name('ai.anime-meta-bulk')->middleware(['admin']); + Route::post('ai/anime-meta-ids', [Admin\AiController::class, 'animeMetaIds'])->name('ai.anime-meta-ids')->middleware(['admin']); + Route::post('ai/fill-anime-meta', [Admin\AiController::class, 'fillAnimeMeta'])->name('ai.fill-anime-meta')->middleware(['admin']); + + // Analitik — genel sadece admin, kullanıcı analitik permission ile + Route::get('analytics', [AdminAnalyticsController::class, 'index'])->name('analytics.index')->middleware(['admin:analytics.view']); + Route::post('analytics/block-ip', [AdminAnalyticsController::class, 'blockIp'])->name('analytics.block-ip')->middleware(['admin:analytics.block']); + Route::post('analytics/unblock-ip',[AdminAnalyticsController::class, 'unblockIp'])->name('analytics.unblock-ip')->middleware(['admin:analytics.block']); + Route::get('stats', [\App\Http\Controllers\Admin\ContentStatsController::class, 'index'])->name('stats.index')->middleware(['admin']); + Route::get('health', [\App\Http\Controllers\Admin\HealthController::class, 'index'])->name('health.index')->middleware(['admin']); + Route::delete('health/anime/{anime}', [\App\Http\Controllers\Admin\HealthController::class, 'deleteAnime'])->name('health.delete-anime')->middleware(['admin']); + Route::post('health/anime/{anime}/delete-source', [\App\Http\Controllers\Admin\HealthController::class, 'deleteSourceEpisodes'])->name('health.delete-source-episodes')->middleware(['admin']); + Route::get('health/storage-stats', [\App\Http\Controllers\Admin\HealthController::class, 'storageStats'])->name('health.storage-stats')->middleware(['admin']); + Route::post('health/cleanup-storage', [\App\Http\Controllers\Admin\HealthController::class, 'cleanupStorage'])->name('health.cleanup-storage')->middleware(['admin']); + Route::get('health/session-info', [\App\Http\Controllers\Admin\HealthController::class, 'sessionInfo'])->name('health.session-info')->middleware(['admin']); + + // Anime istekleri + Route::get('anime-requests', [\App\Http\Controllers\Admin\AnimeRequestController::class, 'index'])->name('anime-requests.index')->middleware(['admin:requests.manage']); + Route::patch('anime-requests/{animeRequest}', [\App\Http\Controllers\Admin\AnimeRequestController::class, 'update'])->name('anime-requests.update')->middleware(['admin:requests.manage']); + Route::delete('anime-requests/{animeRequest}', [\App\Http\Controllers\Admin\AnimeRequestController::class, 'destroy'])->name('anime-requests.destroy')->middleware(['admin:requests.manage']); + + // Bildirim yönetimi + Route::get('notifications', [\App\Http\Controllers\Admin\NotificationController::class, 'index'])->name('notifications.index')->middleware(['admin:notifications.view']); + Route::post('notifications/send', [\App\Http\Controllers\Admin\NotificationController::class, 'send'])->name('notifications.send')->middleware(['admin:notifications.send']); + + // Moderatör Yönetimi — sadece admin + Route::middleware(['admin'])->group(function () { + Route::get('moderators', [\App\Http\Controllers\Admin\ModeratorController::class, 'index'])->name('moderators.index'); + Route::post('moderators/promote', [\App\Http\Controllers\Admin\ModeratorController::class, 'promote'])->name('moderators.promote'); + Route::post('moderators/{user}/demote', [\App\Http\Controllers\Admin\ModeratorController::class, 'demote'])->name('moderators.demote'); + Route::get('moderators/{user}/edit', [\App\Http\Controllers\Admin\ModeratorController::class, 'edit'])->name('moderators.edit'); + Route::post('moderators/{user}/permissions', [\App\Http\Controllers\Admin\ModeratorController::class, 'savePermissions'])->name('moderators.permissions'); + Route::post('moderators/{user}/toggle-permission', [\App\Http\Controllers\Admin\ModeratorController::class, 'togglePermission'])->name('moderators.toggle-permission'); + }); + + // Kullanıcı Analitiği + Route::get('analytics/users', [\App\Http\Controllers\Admin\UserAnalyticsController::class, 'index'])->name('analytics.users')->middleware(['admin:analytics.users']); + Route::get('analytics/users/{user}', [\App\Http\Controllers\Admin\UserAnalyticsController::class, 'userDetail'])->name('analytics.user-detail')->middleware(['admin:analytics.users']); + + // Mobil Uygulama Yönetimi — sadece admin + Route::get('mobile-app', [\App\Http\Controllers\Admin\MobileAppController::class, 'index'])->name('mobile.index')->middleware(['admin']); + Route::post('mobile-app', [\App\Http\Controllers\Admin\MobileAppController::class, 'update'])->name('mobile.update')->middleware(['admin']); + + // Import (Anime çekme) + Route::get('import', [Admin\ImportController::class, 'index'])->name('import.index')->middleware(['admin:import.manage']); + Route::post('import', [Admin\ImportController::class, 'store'])->name('import.store')->middleware(['admin:import.manage']); + Route::get('import/{import}', [Admin\ImportController::class, 'show'])->name('import.show')->middleware(['admin:import.manage']); + Route::delete('import/{import}', [Admin\ImportController::class, 'destroy'])->name('import.destroy')->middleware(['admin:import.manage']); + Route::delete('import-failed', [Admin\ImportController::class, 'destroyFailed'])->name('import.destroyFailed')->middleware(['admin:import.manage']); + Route::delete('import-pending', [Admin\ImportController::class, 'destroyPending'])->name('import.destroyPending')->middleware(['admin:import.manage']); + Route::delete('import-stuck', [Admin\ImportController::class, 'destroyStuck'])->name('import.destroyStuck')->middleware(['admin:import.manage']); + Route::delete('import-by-status', [Admin\ImportController::class, 'destroyByStatus'])->name('import.destroyByStatus')->middleware(['admin:import.manage']); + Route::get('import-bulk-counts', [Admin\ImportController::class, 'bulkCounts'])->name('import.bulkCounts')->middleware(['admin:import.manage']); + // Import araçları (terminal gerektirmez) + Route::post('import-tools/fix-subtitles', [Admin\ImportController::class, 'fixSubtitles'])->name('import.fixSubtitles')->middleware(['admin:import.manage']); + Route::post('import-tools/requeue-anizium', [Admin\ImportController::class, 'requeueAnizium'])->name('import.requeueAnizium')->middleware(['admin:import.manage']); + Route::post('import-tools/requeue-animecix', [Admin\ImportController::class, 'requeueAnimecix'])->name('import.requeueAnimecix')->middleware(['admin:import.manage']); + Route::get('import-tools/source-stats', [Admin\ImportController::class, 'sourceStats'])->name('import.sourceStats')->middleware(['admin:import.manage']); + + // SEO Paneli — sadece admin + Route::middleware(['admin'])->group(function () { + Route::get('seo', [\App\Http\Controllers\Admin\SeoController::class, 'index'])->name('seo.index'); + Route::post('seo', [\App\Http\Controllers\Admin\SeoController::class, 'update'])->name('seo.update'); + Route::post('seo/robots', [\App\Http\Controllers\Admin\SeoController::class, 'updateRobots'])->name('seo.robots'); + Route::post('seo/ping', [\App\Http\Controllers\Admin\SeoController::class, 'pingSearchEngines'])->name('seo.ping'); + Route::get('seo/audit', [\App\Http\Controllers\Admin\SeoController::class, 'auditJson'])->name('seo.audit'); + Route::post('seo/keywords', [\App\Http\Controllers\Admin\SeoController::class, 'storeKeyword'])->name('seo.keywords.store'); + Route::delete('seo/keywords/{keyword}', [\App\Http\Controllers\Admin\SeoController::class, 'destroyKeyword'])->name('seo.keywords.destroy'); + Route::post('seo/redirects', [\App\Http\Controllers\Admin\SeoController::class, 'storeRedirect'])->name('seo.redirects.store'); + Route::delete('seo/redirects/{redirect}', [\App\Http\Controllers\Admin\SeoController::class, 'destroyRedirect'])->name('seo.redirects.destroy'); + Route::post('seo/redirects/{redirect}/toggle', [\App\Http\Controllers\Admin\SeoController::class, 'toggleRedirect'])->name('seo.redirects.toggle'); + Route::post('seo/anime-bulk', [\App\Http\Controllers\Admin\SeoController::class, 'bulkSaveAnime'])->name('seo.anime-bulk'); + Route::post('seo/anime/{anime}/generate', [\App\Http\Controllers\Admin\SeoController::class, 'generateAnimeSeo'])->name('seo.anime.generate'); + Route::post('seo/anime-bulk-generate', [\App\Http\Controllers\Admin\SeoController::class, 'bulkGenerateAllSeo'])->name('seo.anime.bulk-generate'); + Route::post('seo/ai-bulk-generate', [\App\Http\Controllers\Admin\SeoController::class, 'aiBulkGenerateSeo'])->name('seo.ai.bulk-generate'); + Route::post('seo/bulk-fill-batch', [\App\Http\Controllers\Admin\SeoController::class, 'bulkFillBatch'])->name('seo.bulk-fill-batch'); + Route::get('seo/bulk-fill-stats', [\App\Http\Controllers\Admin\SeoController::class, 'bulkFillStats'])->name('seo.bulk-fill-stats'); + Route::post('seo/pagespeed', [\App\Http\Controllers\Admin\SeoController::class, 'pagespeedCheck'])->name('seo.pagespeed'); + Route::get('seo/internal-links', [\App\Http\Controllers\Admin\SeoController::class, 'internalLinksAudit'])->name('seo.internal-links'); + Route::get('seo/duplicate-content', [\App\Http\Controllers\Admin\SeoController::class, 'duplicateContent'])->name('seo.duplicate-content'); + Route::post('seo/ai/chat', [\App\Http\Controllers\Admin\SeoController::class, 'aiChat'])->name('seo.ai.chat'); + Route::post('seo/ai/anime/{anime}/generate', [\App\Http\Controllers\Admin\SeoController::class, 'aiGenerateAnimeSeo'])->name('seo.ai.anime-generate'); + Route::post('seo/ai/keyword-suggest', [\App\Http\Controllers\Admin\SeoController::class, 'aiKeywordSuggest'])->name('seo.ai.keyword-suggest'); + Route::post('seo/ai/page-analysis', [\App\Http\Controllers\Admin\SeoController::class, 'aiPageAnalysis'])->name('seo.ai.page-analysis'); + Route::post('seo/ai/faq-schema', [\App\Http\Controllers\Admin\SeoController::class, 'aiFaqSchema'])->name('seo.ai.faq-schema'); + Route::post('seo/ai/content-strategy', [\App\Http\Controllers\Admin\SeoController::class, 'aiContentStrategy'])->name('seo.ai.content-strategy'); + Route::post('seo/ai/robots-txt', [\App\Http\Controllers\Admin\SeoController::class, 'aiRobotsTxt'])->name('seo.ai.robots-txt'); + }); + + // Blog Yönetimi — sadece admin + Route::middleware(['admin'])->group(function () { + Route::get('blog', [\App\Http\Controllers\Admin\BlogController::class, 'index'])->name('blog.index'); + Route::get('blog/create', [\App\Http\Controllers\Admin\BlogController::class, 'create'])->name('blog.create'); + Route::post('blog', [\App\Http\Controllers\Admin\BlogController::class, 'store'])->name('blog.store'); + Route::get('blog/{blog}/edit', [\App\Http\Controllers\Admin\BlogController::class, 'edit'])->name('blog.edit'); + Route::put('blog/{blog}', [\App\Http\Controllers\Admin\BlogController::class, 'update'])->name('blog.update'); + Route::delete('blog/{blog}', [\App\Http\Controllers\Admin\BlogController::class, 'destroy'])->name('blog.destroy'); + Route::post('blog/generate-ai', [\App\Http\Controllers\Admin\BlogController::class, 'generateAi'])->name('blog.generate-ai'); + Route::post('blog/bulk-generate', [\App\Http\Controllers\Admin\BlogController::class, 'bulkGenerate'])->name('blog.bulk-generate'); + }); +}); + +// ── Sitemap ────────────────────────────────────────────────────────────────── +Route::get('/sitemap.xml', [\App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap'); +Route::get('/sitemap-main.xml', [\App\Http\Controllers\SitemapController::class, 'main'])->name('sitemap.main'); +Route::get('/sitemap-animes.xml', [\App\Http\Controllers\SitemapController::class, 'animes'])->name('sitemap.animes'); +Route::get('/sitemap-videos.xml', [\App\Http\Controllers\SitemapController::class, 'videos'])->name('sitemap.videos'); +Route::get('/sitemap-blog.xml', [\App\Http\Controllers\SitemapController::class, 'blog'])->name('sitemap.blog'); + +// ── Frontend ───────────────────────────────────────────────────────────────── +Route::get('/', [Frontend\HomeController::class, 'index'])->name('home'); +Route::get('/search', [Frontend\HomeController::class, 'search'])->name('search'); +Route::get('/search/suggest', [Frontend\HomeController::class, 'searchSuggest'])->name('search.suggest'); +Route::get('/genre/{genre:slug}', [Frontend\HomeController::class, 'genre'])->name('genre'); +Route::get('/anime/{anime:slug}', [Frontend\AnimeController::class, 'show'])->name('anime.show'); +Route::get('/watch/{anime:slug}/{season}/{episode}', [Frontend\PlayerController::class, 'watch'])->where(['season' => '[0-9]+', 'episode' => '[0-9]+'])->name('watch')->middleware('secure.player'); + +// Keşfet (Tinder-style discovery) +Route::get('/kesfet', [DiscoverController::class, 'index'])->name('discover'); +Route::get('/kesfet/cards', [DiscoverController::class, 'cards'])->name('discover.cards'); +Route::post('/kesfet/swipe', [DiscoverController::class, 'swipe'])->name('discover.swipe'); +Route::post('/kesfet/reset', [DiscoverController::class, 'reset'])->name('discover.reset'); +Route::get('/kesfet/results', [DiscoverController::class, 'results'])->name('discover.results'); + +// Blog +Route::get('/blog', [\App\Http\Controllers\Frontend\BlogController::class, 'index'])->name('blog.index'); +Route::get('/blog/{slug}', [\App\Http\Controllers\Frontend\BlogController::class, 'show'])->name('blog.show'); + +// Kullanıcı arama (share modal için) +Route::middleware('auth')->get('/api/users/search', function(\Illuminate\Http\Request $req) { + $q = trim($req->query('q', '')); + if (strlen($q) < 2) return response()->json([]); + $me = auth()->id(); + $users = \App\Models\User::where('id', '!=', $me) + ->where(fn($query) => $query->where('name','like',"%{$q}%")->orWhere('username','like',"%{$q}%")) + ->limit(8) + ->get() + ->map(fn($u) => [ + 'id' => $u->id, + 'name' => $u->name, + 'username' => $u->username, + 'avatar' => $u->avatar ? \App\Support\MediaUrl::fromStoragePath($u->avatar) : null, + ]); + return response()->json($users); +})->name('api.users.search'); + +// Public profil +Route::get('/u/{user}', [Frontend\ProfileController::class, 'publicProfile'])->name('user.profile'); +Route::get('/u/{user}/compatibility', [SocialController::class, 'compatibility'])->name('user.compatibility'); +Route::get('/u/{user}/card', [SocialController::class, 'card'])->name('user.card'); + +// Watch Party (public read, auth write) +Route::get('/party/{roomCode}', [SocialController::class, 'partyShow'])->name('watch.party'); + +// NicoNico timestamp yorumları (okuma herkese açık) +Route::get('/episode/{episode}/timestamp-comments', [SocialController::class, 'timestampComments'])->name('episode.timestamp-comments'); + +// Tahmin sayısı (herkese açık) +Route::get('/episode/{episode}/predictions', [SocialController::class, 'predictions'])->name('episode.predictions'); + +// İlk kez izleyenler sayısı (herkese açık) +Route::get('/episode/{episode}/first-watch-count', [SocialController::class, 'firstWatchCount'])->name('episode.first-watch-count'); + +// Premium / Planlar +Route::get('/premium', [\App\Http\Controllers\Frontend\PremiumController::class, 'plans'])->name('premium.plans'); + +// Aktivasyon kodu +Route::get('/premium/activate', [\App\Http\Controllers\Frontend\ActivationController::class, 'show'])->name('premium.activate'); +Route::middleware('auth')->post('/premium/activate', [\App\Http\Controllers\Frontend\ActivationController::class, 'redeem'])->name('premium.activate.redeem'); + +// Checkout (ödeme — iyzico, ileride kullanılabilir) +Route::middleware('auth')->group(function () { + Route::get('/checkout/{plan}', [\App\Http\Controllers\Frontend\CheckoutController::class, 'show'])->name('checkout.show'); + Route::post('/checkout/{plan}', [\App\Http\Controllers\Frontend\CheckoutController::class, 'initialize'])->name('checkout.initialize'); + Route::get('/checkout/success', [\App\Http\Controllers\Frontend\CheckoutController::class, 'success'])->name('checkout.success'); + Route::get('/checkout/failed', [\App\Http\Controllers\Frontend\CheckoutController::class, 'failed'])->name('checkout.failed'); +}); +Route::post('/checkout/callback', [\App\Http\Controllers\Frontend\CheckoutController::class, 'callback'])->name('checkout.callback'); + +// ── Frontend — Auth required ────────────────────────────────────────────────── +Route::middleware('auth')->group(function () { + Route::get('/profile', [ProfileController::class, 'show'])->name('profile'); + Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings'); + Route::post('/profile/settings', [ProfileController::class, 'update'])->name('profile.update'); + Route::post('/profile/avatar', [ProfileController::class, 'updateAvatar'])->name('profile.avatar'); + Route::post('/profile/banner', [ProfileController::class, 'updateBanner'])->name('profile.banner'); + Route::post('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password'); + Route::post('/profile/premium-cosmetics', [\App\Http\Controllers\Frontend\PremiumController::class, 'saveCosmetics'])->name('premium.cosmetics.save'); + Route::post('/comments', [CommentController::class, 'store'])->name('comments.store')->middleware('throttle:12,1'); + Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like')->middleware('throttle:60,1'); + + // AI Hub + Route::get('/ai', [AiController::class, 'index'])->name('ai.index'); + + // Kullanıcı özellikleri + Route::get('/watchlist', [UserFeatureController::class, 'watchlistIndex'])->name('watchlist'); + Route::get('/watchlist/export', [UserFeatureController::class, 'watchlistExport'])->name('watchlist.export'); + Route::post('/watchlist/{anime}/toggle', [UserFeatureController::class, 'watchlistToggle'])->name('watchlist.toggle'); + Route::post('/episode/{episode}/vote', [UserFeatureController::class, 'episodeVote'])->name('episode.vote'); + Route::post('/anime/{anime}/rate', [UserFeatureController::class, 'animeRate'])->name('anime.rate'); + Route::post('/continue-watching', [UserFeatureController::class, 'continueWatchingUpdate'])->name('continue.update'); + + // Anime istekleri (store + vote auth gerektirir) + Route::post('/anime-request', [UserFeatureController::class, 'requestStore'])->name('anime.request.store'); + Route::post('/anime-request/{animeRequest}/vote', [UserFeatureController::class, 'requestVote'])->name('anime.request.vote'); + + // Takip + Route::post('/anime/{anime:slug}/follow', [UserFeatureController::class, 'followToggle'])->name('anime.follow'); + + // Kullanıcı takip + Route::post('/u/{user}/follow', [SocialController::class, 'followToggle'])->name('user.follow'); + + // Mesajlar + Route::get('/messages', [MessageController::class, 'index'])->name('messages.index'); + Route::get('/messages/conversations', [MessageController::class, 'conversationsJson'])->name('messages.conversations'); + Route::post('/messages/quick-share', [MessageController::class, 'quickShare'])->name('messages.quick-share'); + Route::get('/messages/{conversation}', [MessageController::class, 'show'])->name('messages.show'); + Route::post('/messages/{conversation}/send', [MessageController::class, 'send'])->name('messages.send')->middleware('throttle:30,1'); + Route::get('/messages/{conversation}/poll', [MessageController::class, 'poll'])->name('messages.poll'); + Route::post('/messages/start/{user}', [MessageController::class, 'startOrOpen'])->name('messages.start'); + Route::post('/messages/upload-image', [MessageController::class, 'uploadImage'])->name('messages.upload-image')->middleware('throttle:20,1'); + + // Sesli Arama + Route::post('/calls/initiate', [VoiceCallController::class, 'initiate'])->name('calls.initiate')->middleware('throttle:10,1'); + Route::post('/calls/{call}/answer', [VoiceCallController::class, 'answer'])->name('calls.answer'); + Route::post('/calls/{call}/decline', [VoiceCallController::class, 'decline'])->name('calls.decline'); + Route::post('/calls/{call}/end', [VoiceCallController::class, 'end'])->name('calls.end'); + Route::get('/calls/poll', [VoiceCallController::class, 'poll'])->name('calls.poll'); + + // ── Sosyal Özellikler (auth gerektirir) ────────────────────────────────── + // NicoNico yorum yaz + Route::post('/episode/{episode}/timestamp-comments', [SocialController::class, 'timestampCommentStore'])->name('episode.timestamp-comment.store')->middleware('throttle:10,1'); + + // Tahmin oyunu + Route::post('/episode/{episode}/predictions', [SocialController::class, 'predictionStore'])->name('episode.prediction.store')->middleware('throttle:5,1'); + Route::post('/predictions/{prediction}/vote', [SocialController::class, 'predictionVote'])->name('prediction.vote')->middleware('throttle:30,1'); + + // Watch Party + Route::post('/party/create', [SocialController::class, 'partyCreate'])->name('party.create'); + Route::post('/party/{roomCode}/join', [SocialController::class, 'partyJoin'])->name('party.join'); + Route::post('/party/{roomCode}/sync', [SocialController::class, 'partySync'])->name('party.sync'); + Route::post('/party/{roomCode}/leave', [SocialController::class, 'partyLeave'])->name('party.leave'); + + // İlk kez izleyenler — kayıt + Route::post('/episode/{episode}/first-watch', [SocialController::class, 'firstWatchRegister'])->name('episode.first-watch'); + + // Bildirimler + Route::get('/notifications', [UserFeatureController::class, 'notificationsIndex'])->name('notifications'); + + // Bölüm notları + Route::post('/episode/{episode}/note', [UserFeatureController::class, 'noteStore'])->name('episode.note.store'); + Route::delete('/notes/{note}', [UserFeatureController::class, 'noteDelete'])->name('episode.note.delete'); + Route::get('/episode/{episode}/notes', [UserFeatureController::class, 'episodeNotesList'])->name('episode.notes.list'); + Route::post('/ai/chat', [AiController::class, 'chat'])->name('ai.chat')->middleware('throttle:25,1'); + Route::post('/ai/recommend', [AiController::class, 'recommend'])->name('ai.recommend')->middleware('throttle:20,1'); + Route::post('/ai/search', [AiController::class, 'search'])->name('ai.search')->middleware('throttle:30,1'); + Route::post('/ai/episode-info', [AiController::class, 'episodeInfo'])->name('ai.episode-info')->middleware('throttle:20,1'); + Route::post('/ai/similar', [AiController::class, 'similar'])->name('ai.similar')->middleware('throttle:20,1'); +}); +// Anime istekleri listesi (herkese açık) +Route::get('/anime-request', [UserFeatureController::class, 'requestIndex'])->name('anime.request'); + +// Bildirim sayısı (AJAX, herkese açık — auth kontrolü controller'da) +Route::get('/notifications/count', [UserFeatureController::class, 'notificationsCount'])->name('notifications.count'); +Route::get('/messages/unread-count', [MessageController::class, 'unreadCount'])->name('messages.unread-count'); + +Route::get('/comments', [CommentController::class, 'index'])->name('comments.index'); + +// ── Tracking (no auth required) ─────────────────────────────────────────────── +Route::post('/track/pageview', [TrackingController::class, 'pageview'])->name('track.pageview'); +Route::post('/track/watch', [TrackingController::class, 'watch'])->name('track.watch'); +Route::post('/track/session-end', [TrackingController::class, 'sessionEnd'])->name('track.session-end'); +Route::get('/comments/gif-search', [CommentController::class, 'gifSearch'])->name('comments.gif-search'); + +// ── Frontend Auth ───────────────────────────────────────────────────────────── +Route::get('/login', [Frontend\AuthController::class, 'showLogin'])->name('frontend.login')->middleware('guest'); +Route::post('/login', [Frontend\AuthController::class, 'login'])->middleware(['guest', 'throttle:10,1']); +Route::get('/register', [Frontend\AuthController::class, 'showRegister'])->name('frontend.register')->middleware('guest'); +Route::post('/register',[Frontend\AuthController::class, 'register'])->middleware(['guest', 'throttle:5,1']); +Route::post('/logout', [Frontend\AuthController::class, 'logout'])->name('frontend.logout')->middleware('auth'); + +// ── Social Auth ─────────────────────────────────────────────────────────────── +Route::get('/auth/{provider}', [Frontend\AuthController::class, 'socialRedirect'])->name('social.redirect')->middleware('guest'); +Route::get('/auth/{provider}/callback', [Frontend\AuthController::class, 'socialCallback'])->name('social.callback'); + +// ── Şifre Sıfırlama ────────────────────────────────────────────────────────── +Route::get('/forgot-password', [Frontend\PasswordResetController::class, 'showForgot'])->name('password.request')->middleware('guest'); +Route::post('/forgot-password', [Frontend\PasswordResetController::class, 'sendResetLink'])->name('password.email')->middleware(['guest','throttle:5,1']); +Route::get('/reset-password/{token}', [Frontend\PasswordResetController::class, 'showReset'])->name('password.reset')->middleware('guest'); +Route::post('/reset-password', [Frontend\PasswordResetController::class, 'reset'])->name('password.update')->middleware(['guest','throttle:5,1']); + +// ── E-posta Doğrulama ──────────────────────────────────────────────────────── +Route::get('/email/verify', [Frontend\EmailVerificationController::class, 'notice'])->name('verification.notice')->middleware('auth'); +Route::get('/email/verify/{id}/{hash}', [Frontend\EmailVerificationController::class, 'verify'])->name('verification.verify')->middleware(['auth','signed']); +Route::post('/email/verification-notification', [Frontend\EmailVerificationController::class, 'resend'])->name('verification.send')->middleware(['auth','throttle:3,1']); + +// HLS Stream Proxy - CORS bypass for external m3u8 sources +Route::get("/stream/proxy", function (\Illuminate\Http\Request $request) { + $u = $request->query("u", ""); + $url = base64_decode($u, true); + if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) abort(400); + + // Block private/internal IPs (SSRF protection) + $host = parse_url($url, PHP_URL_HOST) ?? ""; + if (!$host || preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $host)) abort(403); + // Only allow HTTPS streaming URLs (m3u8 or ts segments) + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ""; + if (!in_array($scheme, ['http', 'https'])) abort(403); + + // Referer: custom ref param > auto-detect from URL's own origin + $customRef = $request->query("ref", ""); + if ($customRef) { + $decoded = base64_decode($customRef, true); + $referer = ($decoded && filter_var($decoded, FILTER_VALIDATE_URL)) ? rtrim($decoded, '/') . '/' : null; + } + if (empty($referer)) { + // Bilinen CDN'ler için doğru Referer'ı zorla — parametre gelmese bile + $knownRefs = [ + 'aniziumserver.sbs' => 'https://anizium.co/', + 'aniziumserver.site' => 'https://anizium.co/', + 'aniziumserver.com' => 'https://anizium.co/', + ]; + $referer = null; + foreach ($knownRefs as $domain => $ref) { + if ($host === $domain || str_ends_with($host, '.' . $domain)) { + $referer = $ref; + break; + } + } + if (!$referer) $referer = $scheme . '://' . $host . '/'; + $customRef = base64_encode($referer); + } + $origin = rtrim($referer, '/'); + + // Tek istek yürüten yardımcı — farklı header setleriyle tekrar denemek için + $fetchUpstream = function (string $url, array $headers) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => $headers, + ]); + $content = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $errNo = curl_errno($ch); + $errStr = $errNo ? curl_strerror($errNo) : ''; + curl_close($ch); + return [$content, $httpCode, $ctype, $errStr]; + }; + + $UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + // 1) Referer-only (gerçek oynatıcı davranışı — Origin göndermez, hotlink koruması daha toleranslı) + [$content, $httpCode, $contentType, $curlErrStr] = $fetchUpstream($url, [ + "Referer: {$referer}", + "User-Agent: {$UA}", + "Accept: */*", + ]); + + // 2) Başarısızsa Origin + Sec-Fetch header'larıyla (tarayıcı taklidi) tekrar dene + if (!$content || $httpCode < 200 || $httpCode >= 400) { + [$content, $httpCode, $contentType, $curlErrStr] = $fetchUpstream($url, [ + "Referer: {$referer}", + "Origin: {$origin}", + "User-Agent: {$UA}", + "Accept: */*", + "Sec-Fetch-Dest: empty", + "Sec-Fetch-Mode: cors", + "Sec-Fetch-Site: cross-site", + ]); + } + + if (!$content || $httpCode < 200 || $httpCode >= 400) { + \Illuminate\Support\Facades\Log::warning('stream_proxy_fail', [ + 'url' => $url, + 'http' => $httpCode, + 'curl' => $curlErrStr, + 'body' => substr((string)$content, 0, 300), + ]); + abort(502); + } + + $path = strtolower(parse_url($url, PHP_URL_PATH) ?? ""); + $isM3u8 = str_ends_with($path, ".m3u8") + || str_contains($contentType, "mpegurl") + || str_starts_with(trim((string)$content), "#EXTM3U"); + + if ($isM3u8) { + $baseUrl = rtrim(dirname($url), "/"); + $refSuffix = "&ref=" . urlencode($customRef); + $proxyBase = url("/stream/proxy") . "?u="; + $segBase = url("/stream/seg") . "?u="; + $lines = explode("\n", str_replace("\r\n", "\n", (string)$content)); + $out = []; + foreach ($lines as $line) { + $t = rtrim($line); + if ($t === "") { $out[] = ""; continue; } + if (str_starts_with($t, "#")) { + // URI="..." attribute → şifreleme anahtarları proxy'den geçsin + $t = preg_replace_callback('/URI="([^"]+)"/', function ($m) use ($baseUrl, $proxyBase, $refSuffix) { + $abs = str_starts_with($m[1], "http") ? $m[1] : $baseUrl . "/" . ltrim($m[1], "/"); + return "URI=\"" . $proxyBase . base64_encode($abs) . $refSuffix . "\""; + }, $t); + $out[] = $t; + } else { + $abs = str_starts_with($t, "http") ? $t : $baseUrl . "/" . ltrim($t, "/"); + $absHost = parse_url($abs, PHP_URL_HOST) ?? ""; + $absPath = strtolower(parse_url($abs, PHP_URL_PATH) ?? ""); + // Sub-playlist (.m3u8) → manifest proxy (kendi segment URL'lerini de düzeltir, ref aktarılır) + if (str_ends_with($absPath, ".m3u8")) { + $out[] = $proxyBase . base64_encode($abs) . $refSuffix; + } else { + // BunnyCDN, animexe CDN → direkt (CORS destekli) + // Diğerleri → /stream/seg (Referer spoofing + önbellekli) + $isTrusted = str_ends_with($absHost, "b-cdn.net") + || str_ends_with($absHost, "animexe.com"); + $out[] = $isTrusted ? $abs : $segBase . base64_encode($abs) . $refSuffix; + } + } + } + return response(implode("\n", $out), 200, [ + "Content-Type" => "application/vnd.apple.mpegurl; charset=utf-8", + "Access-Control-Allow-Origin" => "*", + "Cache-Control" => "no-cache, no-store", + ]); + } + + // Segment proxy: Content-Type düzeltmesi + // .png uzantılı ama aslında video segmenti olan dosyaları düzelt + $segCt = $contentType; + if (str_starts_with($segCt, "image/") || $segCt === "application/octet-stream") { + $segCt = "video/mp2t"; + } + return response($content, 200, [ + "Content-Type" => $segCt ?: "video/mp2t", + "Access-Control-Allow-Origin" => "*", + "Cache-Control" => "public, max-age=3600", + ]); +})->name("stream.proxy"); + +// Segment proxy — önbellekli, Content-Type düzeltmeli (.png → video/mp2t) +// İlk istekte CDN'den çekip diske kaydeder, sonraki istekler PHP'yi atlatır +Route::get("/stream/seg", function (\Illuminate\Http\Request $request) { + $u = $request->query("u", ""); + $url = base64_decode($u, true); + if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) abort(400); + + $host = parse_url($url, PHP_URL_HOST) ?? ""; + if (!$host || preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $host)) abort(403); + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ""; + if (!in_array($scheme, ['http', 'https'])) abort(403); + + // Referer: custom ref param > auto-detect from URL's own origin + $customRef = $request->query("ref", ""); + if ($customRef) { + $decoded = base64_decode($customRef, true); + $referer = ($decoded && filter_var($decoded, FILTER_VALIDATE_URL)) ? rtrim($decoded, '/') . '/' : null; + } + if (empty($referer)) { + $referer = $scheme . '://' . $host . '/'; + } + $origin = rtrim($referer, '/'); + + $cacheDir = storage_path('app/seg_cache'); + if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true); + $cachePath = $cacheDir . '/' . md5($url) . '.ts'; + + // Önbellekte varsa direkt sun + if (file_exists($cachePath)) { + $size = filesize($cachePath); + return response(file_get_contents($cachePath), 200, [ + 'Content-Type' => 'video/mp2t', + 'Content-Length' => $size, + 'Access-Control-Allow-Origin' => '*', + 'Cache-Control' => 'public, max-age=86400', + ]); + } + + // CDN'den çek — önce Referer-only (oynatıcı taklidi), olmazsa Origin ekle + $segFetch = function (string $url, array $headers) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => $headers, + ]); + $content = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$content, $httpCode]; + }; + $segUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + [$content, $httpCode] = $segFetch($url, [ + "Referer: {$referer}", + "User-Agent: {$segUA}", + "Accept: */*", + ]); + if (!$content || $httpCode < 200 || $httpCode >= 400) { + [$content, $httpCode] = $segFetch($url, [ + "Referer: {$referer}", + "Origin: {$origin}", + "User-Agent: {$segUA}", + "Accept: */*", + "Sec-Fetch-Dest: empty", + "Sec-Fetch-Mode: cors", + "Sec-Fetch-Site: cross-site", + ]); + } + + if (!$content || $httpCode < 200 || $httpCode >= 400) abort(502); + + // Diske kaydet (sadece dosya varsa — inode kontrolü) + $segFiles = glob("{$cacheDir}/*.ts") ?: []; + if (count($segFiles) < 300) { + // Max 300 segment tutuyoruz — inode patlamasını önler + file_put_contents($cachePath, $content); + } + + // %10 ihtimalle temizlik: 1 saatten eski segmentleri sil + if (rand(1, 10) === 1) { + foreach (glob("{$cacheDir}/*.ts") ?: [] as $f) { + if (filemtime($f) < time() - 3600) @unlink($f); + } + // Max 300 üzerindeyse en eskileri sil + $files = glob("{$cacheDir}/*.ts") ?: []; + if (count($files) > 300) { + usort($files, fn($a, $b) => filemtime($a) - filemtime($b)); + foreach (array_slice($files, 0, count($files) - 300) as $f) @unlink($f); + } + } + + return response($content, 200, [ + 'Content-Type' => 'video/mp2t', + 'Content-Length' => strlen($content), + 'Access-Control-Allow-Origin' => '*', + 'Cache-Control' => 'public, max-age=86400', + ]); +})->name('stream.seg'); + +// VTT Proxy (CORS bypass for subtitle files) +Route::get('/vtt-proxy', function (\Illuminate\Http\Request $request) { + $url = $request->query('url', ''); + if (!filter_var($url, FILTER_VALIDATE_URL)) abort(400); + $host = parse_url($url, PHP_URL_HOST) ?? ''; + $allowed = ['b-cdn.net', 'bunnycdn.com', 'aniziumserver.site', 'aniziumserver.com', 'aniziumserver.sbs', 'anizium.co']; + $ok = false; + foreach ($allowed as $a) { if (str_ends_with($host, $a)) { $ok = true; break; } } + if (!$ok) abort(403); + try { + $resp = \Illuminate\Support\Facades\Http::timeout(15) + ->withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept' => 'text/vtt,text/plain,*/*', + 'Accept-Language' => 'tr-TR,tr;q=0.9,en;q=0.8', + 'Referer' => 'https://x.anizium.co/', + ]) + ->get($url); + if (!$resp->successful()) abort($resp->status()); + $body = $resp->body(); + // Cloudflare / Anizium hata sayfası dönmüşse (WEBVTT içermiyor) 502 + if (!str_contains(substr($body, 0, 50), 'WEBVTT')) abort(502); + return response($body, 200) + ->header('Content-Type', 'text/vtt; charset=utf-8') + ->header('Cache-Control', 'public, max-age=3600') + ->header('Access-Control-Allow-Origin', '*'); + } catch (\Exception $e) { abort(502); } +})->name('vtt.proxy'); + +// ── Anime Mahkemesi ─────────────────────────────────────────────────────────── +Route::get('/mahkeme', [TribunalController::class, 'index'])->name('tribunal.index'); +Route::get('/mahkeme/{tribunal}', [TribunalController::class, 'show'])->name('tribunal.show'); +Route::get('/anime/{anime}/mahkeme', [TribunalController::class, 'forAnime'])->name('tribunal.for-anime'); +Route::middleware('auth')->group(function () { + Route::post('/mahkeme', [TribunalController::class, 'store'])->name('tribunal.store'); + Route::post('/mahkeme/{tribunal}/oy', [TribunalController::class, 'vote'])->name('tribunal.vote'); + Route::post('/mahkeme/{tribunal}/arguman', [TribunalController::class, 'argue'])->name('tribunal.argue'); + Route::post('/arguman/{argument}/oy', [TribunalController::class, 'argVote'])->name('tribunal.arg-vote'); +}); + +// ── Zaman Kapsülü ───────────────────────────────────────────────────────────── +Route::middleware('auth')->group(function () { + Route::get('/kapsullerim', [SocialController::class, 'capsuleIndex'])->name('capsules.index'); + Route::post('/kapsul', [SocialController::class, 'capsuleStore'])->name('capsule.store'); + Route::post('/kapsul/{capsule}/ac', [SocialController::class, 'capsuleOpen'])->name('capsule.open'); +}); + +// ── Spoiler Kilitli Kutu ────────────────────────────────────────────────────── +Route::get('/episode/{episode}/spoiler-boxes', [SocialController::class, 'spoilerBoxes'])->name('episode.spoiler-boxes'); +Route::middleware('auth')->group(function () { + Route::post('/episode/{episode}/spoiler-boxes', [SocialController::class, 'spoilerBoxStore'])->name('episode.spoiler-box.store'); + Route::post('/spoiler-box/{box}/like', [SocialController::class, 'spoilerBoxLike'])->name('spoiler-box.like'); +}); + +// ── Ruh Hali Motoru ─────────────────────────────────────────────────────────── +Route::post('/mood/recommend', [SocialController::class, 'moodRecommend'])->name('mood.recommend'); + +// ── Yasal Sayfalar ──────────────────────────────────────────────────────────── +Route::get('/kullanim-kosullari', fn() => view('frontend.legal.terms'))->name('legal.terms'); +Route::get('/gizlilik-politikasi', fn() => view('frontend.legal.privacy'))->name('legal.privacy'); +Route::get('/telif-hakki', fn() => view('frontend.legal.dmca'))->name('legal.dmca'); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8364a84 --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,19 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/MediaControllerTest.php b/tests/Feature/MediaControllerTest.php new file mode 100644 index 0000000..5bfa4d8 --- /dev/null +++ b/tests/Feature/MediaControllerTest.php @@ -0,0 +1,33 @@ +put($path, 'fake-image'); + + try { + $response = app(MediaController::class)->show($path); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('fake-image', file_get_contents($response->getFile()->getPathname())); + } finally { + Storage::disk('public')->delete($path); + } + } + + public function test_media_route_is_registered(): void + { + $route = app('router')->getRoutes()->getByName('media.show'); + + $this->assertNotNull($route); + $this->assertSame('media/{path}', $route->uri()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});