Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivationCode;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivationController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('frontend.premium.activate');
|
||||
}
|
||||
|
||||
public function redeem(Request $request)
|
||||
{
|
||||
$request->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Analytics\AiQuery;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiController extends Controller
|
||||
{
|
||||
/**
|
||||
* AI Hub sayfası — kişisel öneri + doğal dil arama.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$genres = Genre::orderBy('name')->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\AnimeFollow;
|
||||
|
||||
class AnimeController extends Controller
|
||||
{
|
||||
public function show(Anime $anime)
|
||||
{
|
||||
abort_unless($anime->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use App\Http\Controllers\Frontend\EmailVerificationController;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
return view('frontend.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BlogPost;
|
||||
use App\Models\Anime;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$posts = BlogPost::with('anime')
|
||||
->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MembershipPlan;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
private function options(): \Iyzipay\Options
|
||||
{
|
||||
$opt = new \Iyzipay\Options();
|
||||
$opt->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comment;
|
||||
use App\Models\CommentLike;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /comments — yorum gönder
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$maxLength = ($user && $user->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\AnimeSwipe;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Watchlist;
|
||||
use App\Services\DeepSeekService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DiscoverController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$genres = Genre::where('is_active', true)->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class EmailVerificationController extends Controller
|
||||
{
|
||||
public function notice()
|
||||
{
|
||||
if (auth()->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Admin\TrendingController;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Banner;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Genre;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\User;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// ── Cache key: 15 dakikada bir rotasyon ──────────────────────────────
|
||||
$rotationSlot = (int) floor(now()->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
try {
|
||||
$conversations = $user->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
public function showForgot()
|
||||
{
|
||||
return view('frontend.auth.forgot-password');
|
||||
}
|
||||
|
||||
public function sendResetLink(Request $request)
|
||||
{
|
||||
$request->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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Season;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Comment;
|
||||
use App\Services\BunnyCdnSigner;
|
||||
use App\Services\AniSkipService;
|
||||
|
||||
class PlayerController extends Controller
|
||||
{
|
||||
// Public wrapper so API controller can call it
|
||||
public static function resolveDubSourcesPublic(?string $m3u8Url, ?array $availableDubs = null): array
|
||||
{
|
||||
return self::resolveDubSourcesFromM3u8($m3u8Url, $availableDubs);
|
||||
}
|
||||
|
||||
public function watch(Anime $anime, int $season, int $episode)
|
||||
{
|
||||
abort_unless($anime->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<int, array{key:string,label:string,url:string,active:bool}>, 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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\PremiumFeatures;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PremiumController extends Controller
|
||||
{
|
||||
/** Kullanıcının premium kozmetik ayarlarını kaydet */
|
||||
public function saveCosmetics(Request $request)
|
||||
{
|
||||
$user = auth()->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AnimeSwipe;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\EpisodeNote;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$recentComments = Comment::where('user_id', $user->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\EpisodePrediction;
|
||||
use App\Models\EpisodeTimestampComment;
|
||||
use App\Models\FirstWatchSession;
|
||||
use App\Models\PredictionVote;
|
||||
use App\Models\SpoilerBox;
|
||||
use App\Models\SpoilerBoxLike;
|
||||
use App\Models\TimeCapsule;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFollow;
|
||||
use App\Models\WatchParty;
|
||||
use App\Models\WatchPartyMember;
|
||||
use App\Services\DeepSeekService;
|
||||
use App\Support\MediaUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 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 = 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\PageView;
|
||||
use App\Models\Analytics\WatchEvent;
|
||||
use App\Models\Analytics\VisitorSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class TrackingController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /track/pageview
|
||||
*/
|
||||
public function pageview(Request $request)
|
||||
{
|
||||
$data = $request->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Tribunal;
|
||||
use App\Models\TribunalArgument;
|
||||
use App\Models\TribunalArgumentVote;
|
||||
use App\Models\TribunalVote;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TribunalController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$tribunals = Tribunal::with(['anime', 'creator'])
|
||||
->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Anime;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Watchlist;
|
||||
use App\Models\EpisodeVote;
|
||||
use App\Models\AnimeRating;
|
||||
use App\Models\AnimeRequest;
|
||||
use App\Models\AnimeRequestVote;
|
||||
use App\Models\ContinueWatching;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\AnimeFollow;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\EpisodeNote;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserFeatureController extends Controller
|
||||
{
|
||||
// ── Watchlist ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function watchlistIndex()
|
||||
{
|
||||
$items = Watchlist::where('user_id', auth()->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontend;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VoiceCall;
|
||||
use App\Models\User;
|
||||
use App\Services\AgoraTokenService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VoiceCallController extends Controller
|
||||
{
|
||||
public function initiate(Request $request)
|
||||
{
|
||||
$request->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user