Initial commit: Animexe Laravel platform

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:01:48 +03:00
co-authored by Claude Opus 4.8
commit a63515cfc6
366 changed files with 74773 additions and 0 deletions
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Ad;
class AdApiController extends Controller
{
// POST /api/ads/{ad}/impression
public function impression(Ad $ad)
{
$ad->increment('impressions');
return response()->json(['ok' => true]);
}
// POST /api/ads/{ad}/click
public function click(Ad $ad)
{
$ad->increment('clicks');
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontend\AiController;
use Illuminate\Http\Request;
class AiApiController extends Controller
{
private AiController $ai;
public function __construct(AiController $ai)
{
$this->ai = $ai;
}
public function chat(Request $request)
{
return $this->ai->chat($request);
}
public function recommend(Request $request)
{
return $this->ai->recommend($request);
}
public function similar(Request $request)
{
return $this->ai->similar($request);
}
public function search(Request $request)
{
return $this->ai->search($request);
}
}
@@ -0,0 +1,534 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\Genre;
use App\Models\Episode;
use App\Models\Banner;
use App\Models\ContinueWatching;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnimeApiController extends Controller
{
// GET /api/home
public function home(Request $request)
{
$featured = Anime::where('is_featured', true)->where('is_published', true)
->with('genres', 'seasons', 'episodes')
->latest()->take(5)->get();
if ($featured->isEmpty()) {
$featured = Anime::where('is_published', true)->with('genres', 'seasons', 'episodes')
->where('rating', '>=', 1)->orderByDesc('rating')->take(5)->get();
}
$latest = Anime::where('is_published', true)->latest()->take(20)->get();
$topRated = Anime::where('is_published', true)->where('rating', '>=', 7)
->orderByDesc('rating')->take(12)->get();
try {
$manualTrending = Anime::where('is_trending', true)->where('is_published', true)
->orderBy('trending_order')->take(12)->get();
if ($manualTrending->count() >= 6) {
$trending = $manualTrending->take(12);
} else {
$autoIds = $manualTrending->pluck('id')->toArray();
$autoFill = Anime::where('is_published', true)
->whereNotIn('id', $autoIds)
->withSum(['episodes as recent_views' => fn($q) =>
$q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30))
], 'view_count')
->orderByDesc('recent_views')
->take(12 - $manualTrending->count())->get();
$trending = $manualTrending->concat($autoFill);
}
} catch (\Throwable) {
$trending = collect();
}
if ($trending->isEmpty()) $trending = $latest->take(12);
$newEpisodes = Episode::with(['anime', 'season'])
->where('is_published', true)->latest()->take(12)->get()
->filter(fn($e) => $e->anime && $e->season)->values();
$genres = Genre::where('is_active', true)->take(16)->get();
$continueWatching = collect();
$recommended = collect();
$authUser = auth('sanctum')->user();
if ($authUser) {
try {
$continueWatching = ContinueWatching::where('user_id', $authUser->id)
->with('anime:id,title,slug,cover_image')
->where('percent_complete', '>=', 5)
->where('percent_complete', '<', 95)
->orderByDesc('updated_at')->limit(10)->get();
$watchedIds = ContinueWatching::where('user_id', $authUser->id)->pluck('anime_id');
if ($watchedIds->isNotEmpty()) {
$topGenreIds = DB::table('anime_genre')
->whereIn('anime_id', $watchedIds)
->select('genre_id', DB::raw('count(*) as cnt'))
->groupBy('genre_id')->orderByDesc('cnt')->limit(3)->pluck('genre_id');
if ($topGenreIds->isNotEmpty()) {
$recommended = Anime::where('is_published', true)
->whereNotIn('id', $watchedIds)
->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds))
->where('rating', '>=', 6)->inRandomOrder()->take(12)->get();
}
}
} catch (\Throwable $e) {}
}
return response()->json([
'featured' => $featured->map(fn($a) => $this->animeResource($a, true)),
'trending' => $trending->values()->map(fn($a) => $this->animeResource($a)),
'latest' => $latest->map(fn($a) => $this->animeResource($a)),
'top_rated' => $topRated->map(fn($a) => $this->animeResource($a)),
'new_episodes' => $newEpisodes->map(fn($e) => $this->episodeCardResource($e)),
'genres' => $genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]),
'continue_watching'=> $continueWatching->map(fn($cw) => $this->continueWatchingResource($cw)),
'recommended' => $recommended->map(fn($a) => $this->animeResource($a)),
]);
}
// GET /api/animes
public function index(Request $request)
{
$q = $request->input('q', '');
$genre = $request->input('genre');
$type = $request->input('type');
$status = $request->input('status');
$year = $request->input('year');
$sort = $request->input('sort', 'latest'); // latest|rating|views
$query = Anime::where('is_published', true)->with('genres');
if ($q) {
$query->where(function ($qb) use ($q) {
$qb->where('title', 'like', "%$q%")
->orWhere('title_en', 'like', "%$q%")
->orWhere('title_jp', 'like', "%$q%");
});
}
if ($genre) $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre));
if ($type) $query->where('type', $type);
if ($status) $query->where('status', $status);
if ($year) $query->where('release_year', $year);
match ($sort) {
'rating' => $query->orderByDesc('rating'),
default => $query->latest(),
};
$results = $query->paginate(24)->withQueryString();
return response()->json([
'data' => collect($results->items())->map(fn($a) => $this->animeResource($a)),
'total' => $results->total(),
'per_page' => $results->perPage(),
'current_page'=> $results->currentPage(),
'last_page' => $results->lastPage(),
]);
}
// GET /api/animes/{slug}
public function show(Request $request, string $slug)
{
$anime = Anime::where('slug', $slug)->where('is_published', true)
->with(['genres', 'seasons', 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number')])
->firstOrFail();
$userId = $request->user()?->id;
$inWatchlist = false;
$userRating = null;
$isFollowing = false;
if ($userId) {
$wl = \App\Models\Watchlist::where('user_id', $userId)->where('anime_id', $anime->id)->first();
$inWatchlist = $wl !== null;
$watchlistStatus = $wl?->status;
$userRating = \App\Models\AnimeRating::where('user_id', $userId)->where('anime_id', $anime->id)->value('rating');
$isFollowing = \App\Models\AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->exists();
}
$seasons = $anime->seasons->map(function ($season) {
return [
'id' => $season->id,
'season_number' => $season->season_number,
'title' => $season->title,
'episodes' => $season->episodes->map(fn($ep) => $this->episodeResource($ep)),
];
});
return response()->json([
'anime' => $this->animeResource($anime, true),
'seasons' => $seasons,
'in_watchlist' => $inWatchlist,
'watchlist_status'=> $watchlistStatus ?? null,
'user_rating' => $userRating,
'is_following' => $isFollowing,
]);
}
// GET /api/genres/{slug}
public function genre(Request $request, string $slug)
{
$genre = Genre::where('slug', $slug)->where('is_active', true)->firstOrFail();
$animes = $genre->animes()->where('is_published', true)->latest()->paginate(24);
return response()->json([
'genre' => ['id'=>$genre->id,'name'=>$genre->name,'slug'=>$genre->slug],
'data' => collect($animes->items())->map(fn($a) => $this->animeResource($a)),
'total' => $animes->total(),
'last_page' => $animes->lastPage(),
'current_page' => $animes->currentPage(),
]);
}
// GET /api/genres
public function genres()
{
$genres = Genre::where('is_active', true)->orderBy('name')->get();
return response()->json($genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]));
}
// GET /api/watch/{slug}/{season}/{episode}
public function watch(Request $request, string $slug, int $season, int $episode)
{
$anime = Anime::where('slug', $slug)->where('is_published', true)->firstOrFail();
$seasonModel = $anime->seasons()->where('season_number', $season)->firstOrFail();
$ep = $seasonModel->episodes()->where('episode_number', $episode)->where('is_published', true)->firstOrFail();
$ep->increment('view_count');
$prev = $seasonModel->episodes()->where('episode_number', '<', $episode)->where('is_published', true)->orderByDesc('episode_number')->first();
$next = $seasonModel->episodes()->where('episode_number', '>', $episode)->where('is_published', true)->orderBy('episode_number')->first();
// Cross-season next
if (!$next) {
$nextSeason = $anime->seasons()->where('season_number', $season + 1)->first();
if ($nextSeason) {
$next = $nextSeason->episodes()->where('episode_number', 1)->where('is_published', true)->first();
}
}
// Subtitles
$subtitles = [];
if (method_exists($ep, 'subtitles')) {
$subtitles = $ep->subtitles()->get()->map(fn($s) => [
'label' => $s->label,
'lang' => $s->language,
'url' => $s->url,
'is_default' => (bool)($s->is_default ?? false),
])->values()->toArray();
}
// Dub sources from m3u8 URL
$dubSources = [];
if ($ep->m3u8_url) {
$rawDubs = $ep->available_dubs ?? null;
$availableDubs = is_string($rawDubs) ? json_decode($rawDubs, true) : (is_array($rawDubs) ? $rawDubs : null);
[$dubSources] = \App\Http\Controllers\Frontend\PlayerController::resolveDubSourcesPublic(
$ep->m3u8_url, $availableDubs
);
}
// Quality sources (legacy — episode.video_url / m3u8_url)
$sources = collect();
if ($ep->m3u8_url) $sources->push(['quality'=>'Auto (HLS)','url'=>$ep->m3u8_url,'type'=>'hls']);
if ($ep->video_url) {
$isHls = str_ends_with($ep->video_url, '.m3u8') || str_contains($ep->video_url, 'master.m3u8');
$type = $isHls ? 'hls' : 'mp4';
$sources->push(['quality'=>'Auto','url'=>$ep->video_url,'type'=>$type]);
}
if ($ep->video_url_1080 ?? null) $sources->push(['quality'=>'1080p','url'=>$ep->video_url_1080,'type'=>'mp4']);
if ($ep->video_url_720 ?? null) $sources->push(['quality'=>'720p','url'=>$ep->video_url_720,'type'=>'mp4']);
if ($ep->video_url_480 ?? null) $sources->push(['quality'=>'480p','url'=>$ep->video_url_480,'type'=>'mp4']);
// Çok kaynak desteği (video_sources tablosu)
// Her translator/kaynak bir grup → [{key, label, url, type, quality}]
$multiSources = \App\Models\VideoSource::where('episode_id', $ep->id)
->orderBy('sort_order')
->get()
->groupBy(fn($vs) => $vs->translator_id ?: $vs->label)
->map(function ($group) {
$default = $group->firstWhere('is_default', true) ?? $group->first();
// Tüm kaliteler (1080p, 720p, vb.)
$qualities = $group->map(fn($vs) => [
'quality' => $vs->quality ?: 'Auto',
'url' => $vs->url,
'type' => $vs->type ?? 'mp4',
])->values()->toArray();
return [
'key' => $default->translator_id
?: \Illuminate\Support\Str::slug($default->label ?? 'kaynak'),
'label' => $default->label ?: 'Kaynak',
'url' => $default->url,
'type' => $default->type ?? 'mp4',
'quality' => $default->quality ?: 'Auto',
'source' => $default->source ?? 'animecix',
'qualities' => $qualities,
'is_default'=> (bool) $default->is_default,
];
})
->values()
->toArray();
// Player settings
$skipSeconds = (int) \App\Models\Setting::get('main_video_skip_seconds', 10);
$wmCoverSeconds = (int) \App\Models\Setting::get('watermark_cover_seconds', 11);
$introEnabled = \App\Models\Setting::get('intro_enabled') == '1';
$introUrl = $introEnabled ? (\App\Models\Setting::get('intro_video_url') ?: null) : null;
$introSkipAfter = (int) \App\Models\Setting::get('intro_skip_after', 5);
// AniSkip is fetched via separate /api/aniskip endpoint to avoid blocking video load
$aniSkipData = null;
// All episodes list for in-player episode switcher
$allEpisodes = $seasonModel->episodes()->where('is_published', true)->orderBy('episode_number')
->get()->map(fn($e) => [
'id' => $e->id,
'episode_number' => $e->episode_number,
'season_number' => $season,
'title' => $e->title,
'thumbnail_url' => $e->thumbnail_url ?? null,
]);
return response()->json([
'anime' => ['id'=>$anime->id,'title'=>$anime->title,'slug'=>$anime->slug,'cover_url'=>$anime->coverUrl],
'season' => ['id'=>$seasonModel->id,'season_number'=>$seasonModel->season_number,'title'=>$seasonModel->title],
'episode' => $this->episodeResource($ep),
'sources' => $sources->values(),
'multi_sources'=> $multiSources, // Çok kaynak (Anizium "4K" + AnimeCix çevirmenler)
'dub_sources' => $dubSources,
'subtitles' => $subtitles,
'episodes' => $allEpisodes,
'prev_episode' => $prev ? ['season'=>$prev->season?->season_number ?? $season,'episode'=>$prev->episode_number] : null,
'next_episode' => $next ? ['season'=>$next->season?->season_number ?? $season,'episode'=>$next->episode_number] : null,
'settings' => [
'skip_seconds' => $skipSeconds,
'wm_cover_seconds' => $wmCoverSeconds,
'intro_url' => $introUrl,
'intro_skip_after' => $introSkipAfter,
// AniSkip timestamps (null if not available)
'aniskip' => $aniSkipData, // {'op':{'start':X,'end':Y}, 'ed':{'start':X,'end':Y}}
],
]);
}
// ── Resources ─────────────────────────────────────────────────────────────
private function animeResource(Anime $a, bool $full = false): array
{
$base = [
'id' => $a->id,
'title' => $a->title,
'title_en' => $a->title_en,
'title_jp' => $a->title_jp,
'slug' => $a->slug,
'cover_url' => $a->coverUrl,
'banner_url' => $a->bannerUrl,
'type' => $a->type,
'status' => $a->status,
'rating' => $a->rating ? (float)$a->rating : null,
'release_year' => $a->release_year,
'episode_count' => $a->episode_count,
'genres' => $a->relationLoaded('genres')
? $a->genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])->values()
: [],
];
if ($full) {
$base['description'] = $a->description;
$base['studio'] = $a->studio ?? null;
$base['duration'] = $a->duration ?? null;
$base['is_featured'] = $a->is_featured;
// First episode for "watch now" button
if ($a->relationLoaded('seasons') && $a->seasons->isNotEmpty()) {
$firstSeason = $a->seasons->first();
$eps = $a->relationLoaded('episodes') ? $a->episodes : $firstSeason->episodes;
$firstEp = $eps->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first();
$base['first_watch'] = ($firstSeason && $firstEp) ? [
'season' => $firstSeason->season_number,
'episode' => $firstEp->episode_number,
] : null;
}
}
return $base;
}
private function episodeResource(Episode $ep): array
{
return [
'id' => $ep->id,
'episode_number' => $ep->episode_number,
'title' => $ep->title,
'thumbnail_url' => $ep->thumbnailUrl ?? null,
'duration' => $ep->duration,
'view_count' => $ep->view_count,
'created_at' => $ep->created_at?->toISOString(),
];
}
private function episodeCardResource(Episode $ep): array
{
return [
'id' => $ep->id,
'episode_number' => $ep->episode_number,
'season_number' => $ep->season?->season_number,
'title' => $ep->title,
'thumbnail_url' => $ep->thumbnailUrl ?? null,
'created_at' => $ep->created_at?->diffForHumans(),
'anime' => [
'id' => $ep->anime->id,
'title' => $ep->anime->title,
'slug' => $ep->anime->slug,
'cover_url' => $ep->anime->coverUrl,
'rating' => $ep->anime->rating ? (float)$ep->anime->rating : null,
'description' => $ep->anime->description,
],
];
}
// ── AniSkip endpoint ─────────────────────────────────────────────────────
// GET /api/aniskip/{slug}/{season}/{episode}
// Fully automatic: finds MAL ID by title if missing, caches everything
public function aniSkip(string $slug, int $season, int $episode)
{
$anime = Anime::where('slug', $slug)->first();
if (!$anime) return response()->json(['aniskip' => null]);
$seasonModel = $anime->seasons()->where('season_number', $season)->first();
if (!$seasonModel) return response()->json(['aniskip' => null]);
$seasonMalId = $seasonModel->mal_id;
try {
// anime.mal_id yoksa title search (bir kez, cache'lenir)
if (!$anime->mal_id) {
$found = (new \App\Services\JikanService())->searchMalId($anime->title, $anime->title_en, $anime->title_jp);
if ($found) $anime->update(['mal_id' => $found]);
}
if (!$seasonMalId && $anime->mal_id) {
// S1 için anime.mal_id direkt kullan — Jikan'a gitme
if ($season === 1) {
$seasonMalId = $anime->mal_id;
$seasonModel->update(['mal_id' => $seasonMalId]);
} else {
// Diğer sezonlar: sadece cache'ten bak, yoksa null dön (page load bloke olmasın)
$chain = \Illuminate\Support\Facades\Cache::get("jikan_chain_{$anime->mal_id}");
if ($chain) {
$seasonMalId = $chain[$season - 1] ?? $chain[0] ?? null;
if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]);
}
}
}
if ($seasonMalId) {
$data = (new \App\Services\AniSkipService())->getSkipTimes((string)$seasonMalId, $episode);
return response()->json(['aniskip' => $data]);
}
} catch (\Throwable) {}
return response()->json(['aniskip' => null]);
}
// ── Skip segments ─────────────────────────────────────────────────────────
// POST /api/episodes/{episode}/skip-event (anonim OK, rate-limited)
public function recordSkipEvent(Request $request, Episode $episode)
{
$from = (int) $request->input('from_sec', 0);
$to = (int) $request->input('to_sec', 0);
// Basic sanity: must skip forward at least 5s, not more than 10 min
if ($to <= $from + 4 || ($to - $from) > 600) {
return response()->json(['ok' => false]);
}
DB::table('episode_skip_events')->insert([
'episode_id' => $episode->id,
'from_sec' => $from,
'to_sec' => $to,
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
// GET /api/episodes/{episode}/skip-segments
// Returns segments where >= 10 users skipped from within a 30-second window
public function skipSegments(Episode $episode)
{
// İntro tespiti: ilk 3 dakika içinde 45-150sn ileri atlama = intro skip
// Kümeleme: 20sn bucket, to_sec standart sapması <= 15sn, en az 2 farklı kullanıcı
$rows = DB::table('episode_skip_events')
->where('episode_id', $episode->id)
->where('from_sec', '<', 180)
->whereRaw('(to_sec - from_sec) BETWEEN 45 AND 150')
->selectRaw('
FLOOR(from_sec / 20) * 20 AS bucket_start,
AVG(to_sec) AS avg_to,
STDDEV_POP(to_sec) AS stddev_to,
COUNT(*) AS cnt
')
->groupByRaw('FLOOR(from_sec / 20) * 20')
->havingRaw('cnt >= 2 AND (STDDEV_POP(to_sec) <= 15 OR cnt = 1)')
->orderBy('cnt', 'desc')
->limit(1)
->get();
$segments = $rows->map(fn($r) => [
'from' => (int) $r->bucket_start,
'to' => (int) round($r->avg_to),
'count'=> (int) $r->cnt,
])->values();
return response()->json(['segments' => $segments]);
}
private function continueWatchingResource($cw): array
{
return [
'id' => $cw->id,
'season_number' => $cw->season_number,
'episode_number' => $cw->episode_number,
'percent_complete' => $cw->percent_complete,
'anime' => $cw->anime ? [
'id' => $cw->anime->id,
'title' => $cw->anime->title,
'slug' => $cw->anime->slug,
'cover_url' => \App\Support\MediaUrl::fromStoragePath($cw->anime->cover_image),
] : null,
];
}
// POST /api/sources/flag-hevc
// Player tarafından çağrılır: HEVC hatası alınan kaynak URL'sini DB'ye işler
public function flagHevc(Request $request)
{
$url = $request->input('url');
if (!$url) return response()->json(['ok' => false]);
\App\Models\VideoSource::where('url', $url)->update([
'is_hevc' => true,
'hevc_checked_at' => now(),
]);
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthApiController extends Controller
{
public function register(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'username' => 'required|string|max:50|unique:users|alpha_dash',
'email' => 'required|email|unique:users',
'password' => 'required|string|min:6|confirmed',
]);
$user = User::create([
'name' => $data['name'],
'username' => $data['username'],
'email' => $data['email'],
'password' => $data['password'],
'role' => 'user',
'membership' => 'free',
]);
$token = $user->createToken('animexe-app')->plainTextToken;
return response()->json([
'token' => $token,
'user' => $this->userResource($user),
], 201);
}
public function login(Request $request)
{
$data = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $data['email'])->first();
if (!$user || !Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['E-posta veya şifre hatalı.'],
]);
}
if ($user->is_banned) {
return response()->json([
'message' => 'Hesabınız yasaklandı. Sebep: ' . ($user->ban_reason ?? 'Belirtilmedi'),
], 403);
}
$token = $user->createToken('animexe-app')->plainTextToken;
return response()->json([
'token' => $token,
'user' => $this->userResource($user),
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Çıkış yapıldı.']);
}
public function me(Request $request)
{
return response()->json(['user' => $this->userResource($request->user())]);
}
public function saveFcmToken(Request $request)
{
$data = $request->validate(['token' => 'required|string|max:500']);
$request->user()->update(['fcm_token' => $data['token']]);
return response()->json(['ok' => true]);
}
public function updateProfile(Request $request)
{
$user = $request->user();
$request->validate([
'name' => 'sometimes|string|max:100',
'username' => 'sometimes|string|max:50|unique:users,username,' . $user->id . '|alpha_dash',
'password' => 'sometimes|string|min:6|confirmed',
'bio' => 'sometimes|nullable|string|max:300',
'website' => 'sometimes|nullable|string|max:100',
'twitter' => 'sometimes|nullable|string|max:50',
'instagram' => 'sometimes|nullable|string|max:50',
'discord' => 'sometimes|nullable|string|max:50',
'avatar' => 'sometimes|nullable|image|max:3072',
'banner' => 'sometimes|nullable|image|max:6144',
]);
$data = $request->only(['name', 'username', 'bio', 'website', 'twitter', 'instagram', 'discord']);
$data = array_filter($data, fn($v) => $v !== null);
if ($request->filled('password')) {
$data['password'] = bcrypt($request->input('password'));
}
if ($request->hasFile('avatar')) {
$data['avatar'] = $request->file('avatar')->store('avatars', 'public');
}
if ($request->hasFile('banner')) {
$data['banner_image'] = $request->file('banner')->store('banners', 'public');
}
if (!empty($data)) {
$user->update($data);
}
return response()->json(['user' => $this->userResource($user->fresh())]);
}
private function userResource(User $user): array
{
return [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
'email' => $user->email,
'bio' => $user->bio,
'website' => $user->website,
'twitter' => $user->twitter,
'instagram' => $user->instagram,
'discord' => $user->discord,
'avatar' => $user->avatar
? (\App\Support\MediaUrl::fromStoragePath($user->avatar))
: null,
'banner_image' => $user->banner_image
? (\App\Support\MediaUrl::fromStoragePath($user->banner_image))
: null,
'role' => $user->role,
'membership' => $user->membership,
'is_premium' => $user->isPremium(),
'premium_expires_at' => $user->premium_expires_at?->toISOString(),
'created_at' => $user->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Comment;
use App\Models\CommentLike;
use App\Models\Anime;
use Illuminate\Http\Request;
class CommentApiController extends Controller
{
public function index(Request $request)
{
$animeId = $request->input('anime_id');
$episodeId = $request->input('episode_id');
$query = Comment::with('user:id,name,username,avatar')
->where('status', 'approved')
->orderByDesc('is_pinned')
->orderByDesc('created_at');
if ($episodeId) {
$query->where('commentable_type', \App\Models\Episode::class)
->where('commentable_id', $episodeId);
} elseif ($animeId) {
$query->where('commentable_type', Anime::class)
->where('commentable_id', $animeId);
}
$items = $query->paginate(20);
$userId = $request->user()?->id;
return response()->json([
'data' => collect($items->items())->map(fn($c) => $this->fmt($c, $userId)),
'total' => $items->total(),
'last_page' => $items->lastPage(),
]);
}
public function store(Request $request)
{
$data = $request->validate([
'anime_id' => 'nullable|exists:animes,id',
'episode_id' => 'nullable|exists:episodes,id',
'body' => 'required|string|max:1000',
'gif_url' => 'nullable|url|max:500',
]);
if (empty($data['anime_id']) && empty($data['episode_id'])) {
return response()->json(['error' => 'anime_id veya episode_id gerekli.'], 422);
}
$isEpisode = !empty($data['episode_id']);
$comment = Comment::create([
'user_id' => $request->user()->id,
'commentable_type' => $isEpisode ? \App\Models\Episode::class : Anime::class,
'commentable_id' => $isEpisode ? $data['episode_id'] : $data['anime_id'],
'content' => $data['body'],
'gif_url' => $data['gif_url'] ?? null,
'status' => 'approved',
]);
$comment->load('user:id,name,username,avatar');
return response()->json($this->fmt($comment, $request->user()->id), 201);
}
public function like(Request $request, Comment $comment)
{
$userId = $request->user()->id;
$existing = CommentLike::where('user_id', $userId)
->where('comment_id', $comment->id)->first();
if ($existing) {
$existing->delete();
$comment->decrement('like_count');
return response()->json(['liked' => false, 'likes' => $comment->fresh()->like_count]);
}
CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]);
$comment->increment('like_count');
return response()->json(['liked' => true, 'likes' => $comment->fresh()->like_count]);
}
private function fmt(Comment $c, ?int $userId): array
{
return [
'id' => $c->id,
'body' => $c->content,
'gif_url' => $c->gif_url,
'likes_count' => $c->like_count ?? 0,
'is_pinned' => $c->is_pinned ?? false,
'created_at' => $c->created_at?->diffForHumans(),
'user_liked' => $userId
? CommentLike::where('user_id', $userId)->where('comment_id', $c->id)->exists()
: false,
'user' => $c->user ? [
'id' => $c->user->id,
'name' => $c->user->name,
'username' => $c->user->username,
'avatar' => $c->user->avatar
? \App\Support\MediaUrl::fromStoragePath($c->user->avatar)
: null,
] : null,
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class MessageApiController extends Controller
{
// GET /api/messages — conversations list
public function conversations()
{
$user = Auth::user();
$convs = $user->conversations()
->with(['participants', 'lastMessage.user'])
->orderByDesc('conversations.updated_at')
->limit(50)
->get()
->map(function ($conv) use ($user) {
$other = $conv->participants->firstWhere('id', '!=', $user->id);
$last = $conv->lastMessage;
$unread = $conv->unreadCountFor($user->id);
$preview = null;
if ($last) {
if (str_starts_with($last->body, 'IMAGE::')) $preview = '📷 Fotoğraf';
elseif (str_starts_with($last->body, 'GIF::')) $preview = '🎞 GIF';
elseif (str_starts_with($last->body, 'ANIMESHARE::')) {
try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime'); } catch (\Throwable) {}
} else {
$isMine = $last->user_id === $user->id;
$preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 60);
}
}
return [
'id' => $conv->id,
'other_user' => $other ? [
'id' => $other->id,
'name' => $other->name,
'username' => $other->username,
'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
] : null,
'last_message' => $last ? [
'body' => $preview ?? '',
'user_id' => $last->user_id,
'created_at' => $last->created_at?->toISOString(),
] : null,
'unread_count' => $unread,
'updated_at' => $conv->updated_at?->toISOString(),
];
});
return response()->json(['conversations' => $convs]);
}
// GET /api/messages/{conversation} — messages in a conversation
public function show(Conversation $conversation)
{
$user = Auth::user();
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
$other = $conversation->participants()->where('user_id', '!=', $user->id)->first();
$messages = $conversation->messages()
->with('user')
->orderBy('created_at')
->get()
->map(fn($m) => [
'id' => $m->id,
'user_id' => $m->user_id,
'body' => $m->body,
'created_at' => $m->created_at?->toISOString(),
'author' => [
'id' => $m->user?->id,
'name' => $m->user?->name,
'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
],
]);
// Mark as read
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
return response()->json([
'messages' => $messages,
'other_user' => $other ? [
'id' => $other->id,
'name' => $other->name,
'username' => $other->username,
'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null,
] : null,
]);
}
// POST /api/messages/{conversation} — send a message
public function send(Request $request, Conversation $conversation)
{
$user = Auth::user();
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
$request->validate(['body' => 'required|string|max:5000']);
$message = Message::create([
'conversation_id' => $conversation->id,
'user_id' => $user->id,
'body' => $request->body,
]);
$conversation->touch();
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
return response()->json([
'id' => $message->id,
'user_id' => $user->id,
'body' => $message->body,
'created_at' => $message->created_at->toISOString(),
'author' => [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null,
],
]);
}
// POST /api/messages/start/{user} — start or open conversation
public function startConversation(User $user)
{
$me = Auth::user();
if ($me->id === $user->id) abort(422, 'Kendinize mesaj gönderemezsiniz.');
$conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id))
->whereHas('participants', fn($q) => $q->where('user_id', $user->id))
->first();
if (!$conv) {
$conv = DB::transaction(function () use ($me, $user) {
$c = Conversation::create();
$c->participants()->attach([$me->id, $user->id]);
return $c;
});
}
return response()->json(['conversation_id' => $conv->id]);
}
// GET /api/messages/{conv}/poll?after={id} — poll for new messages (mobile)
public function poll(Request $request, Conversation $conversation)
{
$user = Auth::user();
abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403);
$after = (int) $request->query('after', 0);
$messages = $conversation->messages()
->with('user')
->where('id', '>', $after)
->orderBy('created_at')
->get()
->map(fn($m) => [
'id' => $m->id,
'user_id' => $m->user_id,
'body' => $m->body,
'created_at' => $m->created_at?->toISOString(),
'author' => [
'id' => $m->user?->id,
'name' => $m->user?->name,
'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null,
],
]);
$conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]);
return response()->json(['messages' => $messages]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\MembershipPlan;
use App\Models\Subscription;
use Illuminate\Http\Request;
class PlanApiController extends Controller
{
public function index(Request $request)
{
$plans = MembershipPlan::where('is_active', true)
->where('is_public', true)
->orderBy('sort_order')
->orderBy('price')
->get()
->map(fn($p) => $this->fmtPlan($p));
$subscription = null;
$user = $request->user();
if ($user) {
$sub = Subscription::where('user_id', $user->id)
->where('status', 'active')
->where('expires_at', '>', now())
->with('plan')
->latest()
->first();
if ($sub) {
$subscription = [
'plan_id' => $sub->plan_id,
'plan_name' => $sub->plan?->name,
'plan_slug' => $sub->plan?->slug,
'status' => $sub->status,
'expires_at' => $sub->expires_at?->toISOString(),
];
}
}
return response()->json([
'plans' => $plans,
'subscription' => $subscription,
'is_premium' => $user?->isPremium() ?? false,
]);
}
private function fmtPlan(MembershipPlan $p): array
{
return [
'id' => $p->id,
'name' => $p->name,
'slug' => $p->slug,
'description' => $p->description,
'price' => $p->price,
'purchase_link' => $p->purchase_link,
'duration_days' => $p->duration_days,
'trial_days' => $p->trial_days,
'features' => $p->features ?? [],
'perks' => $p->perks ?? [],
'badge_label' => $p->badge_label,
'accent_color' => $p->accent_color,
];
}
}
@@ -0,0 +1,462 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\Episode;
use App\Models\EpisodePrediction;
use App\Models\EpisodeTimestampComment;
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 SocialApiController extends Controller
{
// ── 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',
]);
return response()->json(['comments' => $comments]);
}
public function timestampCommentStore(Request $request, Episode $episode)
{
$data = $request->validate([
'timestamp_sec' => 'required|integer|min:0|max:86400',
'body' => 'required|string|max:100',
'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
]);
$me = Auth::user();
$recent = EpisodeTimestampComment::where('user_id', $me->id)
->where('episode_id', $episode->id)
->where('created_at', '>=', now()->subSeconds(5))
->count();
if ($recent >= 2) {
return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429);
}
$comment = EpisodeTimestampComment::create([
'episode_id' => $episode->id,
'user_id' => $me->id,
'timestamp_sec' => $data['timestamp_sec'],
'body' => $data['body'],
'color' => $data['color'] ?? '#ffffff',
]);
return response()->json(['ok' => true, 'id' => $comment->id]);
}
// ── Tahmin Oyunu ─────────────────────────────────────────────────────────
public function predictions(Episode $episode)
{
$me = Auth::id();
$predictions = EpisodePrediction::with('user:id,name,username')
->where('episode_id', $episode->id)
->orderByDesc('vote_count')
->get()
->map(fn($p) => [
'id' => $p->id,
'body' => $p->body,
'is_correct' => $p->is_correct,
'vote_count' => $p->vote_count,
'username' => $p->user?->username,
'is_mine' => $me && $p->user_id === $me,
'voted' => $me
? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists()
: false,
'created_at' => $p->created_at->diffForHumans(),
]);
$myPrediction = $me
? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id
: null;
return response()->json([
'predictions' => $predictions,
'my_prediction' => $myPrediction,
]);
}
public function predictionStore(Request $request, Episode $episode)
{
$me = Auth::user();
$data = $request->validate(['body' => 'required|string|min:5|max:280']);
$existing = EpisodePrediction::where('episode_id', $episode->id)
->where('user_id', $me->id)->first();
if ($existing) {
return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422);
}
$prediction = EpisodePrediction::create([
'episode_id' => $episode->id,
'user_id' => $me->id,
'body' => $data['body'],
]);
return response()->json(['ok' => true, 'id' => $prediction->id]);
}
public function predictionVote(EpisodePrediction $prediction)
{
$me = Auth::user();
$existing = PredictionVote::where('prediction_id', $prediction->id)->where('user_id', $me->id)->first();
if ($existing) {
$existing->delete();
$prediction->decrement('vote_count');
return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]);
}
PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]);
$prediction->increment('vote_count');
return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]);
}
// ── Watch Party ──────────────────────────────────────────────────────────
public function partyCreate(Request $request)
{
$me = Auth::user();
$data = $request->validate([
'episode_id' => 'required|exists:episodes,id',
'is_private' => 'boolean',
'password' => 'nullable|string|max:30',
'max_members' => 'nullable|integer|min:2|max:20',
]);
WatchParty::where('host_user_id', $me->id)->delete();
$party = WatchParty::create([
'room_code' => WatchParty::generateCode(),
'host_user_id' => $me->id,
'episode_id' => $data['episode_id'],
'is_private' => $data['is_private'] ?? false,
'password' => isset($data['password']) ? Hash::make($data['password']) : null,
'max_members' => $data['max_members'] ?? 10,
]);
WatchPartyMember::create(['party_id' => $party->id, 'user_id' => $me->id]);
return response()->json([
'ok' => true,
'room_code' => $party->room_code,
'party' => $this->partyData($party),
]);
}
public function partyJoin(Request $request, string $roomCode)
{
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
$me = Auth::user();
if ($party->is_private && $party->password) {
if (!Hash::check($request->input('password', ''), $party->password)) {
return response()->json(['error' => 'Yanlış şifre.'], 403);
}
}
if ($party->activeMembers()->count() >= $party->max_members) {
return response()->json(['error' => 'Oda dolu.'], 403);
}
WatchPartyMember::updateOrCreate(
['party_id' => $party->id, 'user_id' => $me->id],
['last_ping' => now()]
);
return response()->json([
'ok' => true,
'party' => $this->partyData($party),
]);
}
public function partySync(Request $request, string $roomCode)
{
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
$me = Auth::user();
if ($party->host_user_id === $me->id) {
$data = $request->validate([
'current_sec' => 'required|integer|min:0',
'is_playing' => 'required|boolean',
]);
$party->update([
'current_sec' => $data['current_sec'],
'is_playing' => $data['is_playing'],
]);
}
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)
->update(['last_ping' => now()]);
$fresh = $party->fresh();
return response()->json([
'current_sec' => $fresh->current_sec,
'is_playing' => $fresh->is_playing,
'members' => $this->memberList($party),
]);
}
public function partyLeave(string $roomCode)
{
$party = WatchParty::where('room_code', $roomCode)->firstOrFail();
$me = Auth::user();
WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete();
if ($party->host_user_id === $me->id) {
$party->delete();
return response()->json(['ok' => true, 'dissolved' => true]);
}
return response()->json(['ok' => true, 'dissolved' => false]);
}
public function partyInfo(string $roomCode)
{
$party = WatchParty::with(['episode.anime', 'episode.season'])
->where('room_code', $roomCode)->firstOrFail();
return response()->json(['party' => $this->partyData($party)]);
}
private function partyData(WatchParty $party): array
{
$party->loadMissing(['episode.anime', 'episode.season']);
return [
'room_code' => $party->room_code,
'host_id' => $party->host_user_id,
'episode_id' => $party->episode_id,
'current_sec' => $party->current_sec,
'is_playing' => $party->is_playing,
'is_private' => $party->is_private,
'max_members' => $party->max_members,
'members' => $this->memberList($party),
'anime_title' => $party->episode?->anime?->title,
'anime_slug' => $party->episode?->anime?->slug,
'episode_num' => $party->episode?->episode_number,
'season_num' => $party->episode?->season?->season_number ?? 1,
];
}
private function memberList(WatchParty $party): array
{
return $party->activeMembers()->with('user:id,name,username')->get()
->map(fn($m) => [
'id' => $m->user_id,
'name' => $m->user?->name,
'username'=> $m->user?->username,
'is_host' => $m->user_id === $party->host_user_id,
])->toArray();
}
// ── Spoiler Kutular ──────────────────────────────────────────────────────
public function spoilerBoxes(Episode $episode)
{
$me = Auth::id();
$boxes = SpoilerBox::with('user:id,name,username')
->where('episode_id', $episode->id)
->orderByDesc('likes')
->orderByDesc('created_at')
->get()
->map(fn($b) => [
'id' => $b->id,
'body' => $b->body,
'is_spoiler' => $b->is_spoiler,
'spoiler_score' => $b->spoiler_score,
'likes' => $b->likes,
'username' => $b->user?->username,
'is_mine' => $me && $b->user_id === $me,
'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false,
'created_at' => $b->created_at->diffForHumans(),
]);
return response()->json(['boxes' => $boxes]);
}
public function spoilerBoxStore(Request $request, Episode $episode)
{
$me = Auth::user();
$data = $request->validate(['body' => 'required|string|min:3|max:600']);
$isSpoiler = false;
$spoilerScore = 0;
$ai = new DeepSeekService();
if ($ai->isConfigured()) {
try {
$raw = $ai->checkSpoiler($data['body']);
if ($raw) {
$isSpoiler = $raw['is_spoiler'] ?? false;
$spoilerScore = $raw['score'] ?? 0;
}
} catch (\Throwable $e) {}
}
$box = SpoilerBox::create([
'episode_id' => $episode->id,
'user_id' => $me->id,
'body' => $data['body'],
'is_spoiler' => $isSpoiler,
'spoiler_score' => $spoilerScore,
]);
return response()->json(['ok' => true, 'id' => $box->id, 'is_spoiler' => $isSpoiler]);
}
public function spoilerBoxLike(SpoilerBox $box)
{
$me = Auth::id();
$existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first();
if ($existing) {
$existing->delete();
$box->decrement('likes');
return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]);
}
SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]);
$box->increment('likes');
return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]);
}
// ── Zaman Kapsülü ────────────────────────────────────────────────────────
public function capsuleIndex()
{
$capsules = TimeCapsule::with('anime:id,title,slug,cover_image')
->where('user_id', Auth::id())
->orderBy('unlock_at')
->get()
->map(fn($c) => [
'id' => $c->id,
'anime_title' => $c->anime?->title,
'anime_slug' => $c->anime?->slug,
'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null,
'unlock_at' => $c->unlock_at->toIso8601String(),
'unlocked' => $c->isUnlocked(),
'opened' => $c->isOpened(),
'message' => ($c->isOpened() || $c->isUnlocked()) ? $c->message : null,
'created_at' => $c->created_at->toIso8601String(),
]);
return response()->json(['capsules' => $capsules]);
}
public function capsuleStore(Request $request)
{
$me = Auth::user();
$data = $request->validate([
'anime_id' => 'required|exists:animes,id',
'message' => 'required|string|min:5|max:1000',
'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(),
]);
$data['user_id'] = $me->id;
$capsule = TimeCapsule::create($data);
return response()->json(['ok' => true, 'id' => $capsule->id]);
}
public function capsuleOpen(TimeCapsule $capsule)
{
if ($capsule->user_id !== Auth::id()) {
return response()->json(['error' => 'Yetkisiz.'], 403);
}
if (!$capsule->isUnlocked()) {
return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422);
}
$capsule->update(['opened_at' => now()]);
return response()->json(['ok' => true, 'message' => $capsule->message]);
}
// ── Ruh Hali Motoru ──────────────────────────────────────────────────────
private static array $moodGenres = [
'sad' => ['Drama', 'Romantizm'],
'funny' => ['Komedi', 'Slice of Life'],
'hype' => ['Aksiyon', 'Shounen', 'Spor'],
'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'],
'romance' => ['Romantizm', 'Shoujo'],
'scary' => ['Korku', 'Supernatural', 'Gerilim'],
];
public function moodRecommend(Request $request)
{
$mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood'];
$genres = self::$moodGenres[$mood] ?? [];
$animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres))
->where('is_published', true)
->inRandomOrder()
->limit(6)
->get(['id', 'title', 'cover_image', 'slug', 'rating']);
return response()->json([
'animes' => $animes->map(fn($a) => [
'id' => $a->id,
'title' => $a->title,
'slug' => $a->slug,
'cover' => $a->cover_image ? MediaUrl::fromStoragePath($a->cover_image) : null,
'rating' => $a->rating,
]),
]);
}
// ── Kullanıcı Takip ──────────────────────────────────────────────────────
public function followToggle(User $user)
{
$me = Auth::user();
if ($me->id === $user->id) {
return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422);
}
$existing = \App\Models\UserFollow::where('follower_id', $me->id)
->where('following_id', $user->id)->first();
if ($existing) {
$existing->delete();
$following = false;
} else {
\App\Models\UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]);
$following = true;
}
return response()->json([
'following' => $following,
'followers_count' => \App\Models\UserFollow::where('following_id', $user->id)->count(),
]);
}
}
@@ -0,0 +1,205 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tribunal;
use App\Models\TribunalArgument;
use App\Models\TribunalArgumentVote;
use App\Models\TribunalVote;
use App\Support\MediaUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TribunalApiController extends Controller
{
public function index(Request $request)
{
$query = Tribunal::with(['anime:id,title,slug,cover_image', 'creator:id,name,username'])
->withCount('votes');
if ($request->filled('anime_id')) {
$query->where('anime_id', $request->anime_id);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$tribunals = $query->latest()->paginate(15);
return response()->json([
'data' => collect($tribunals->items())->map(fn($t) => $this->formatTribunal($t))->values(),
'has_more' => $tribunals->hasMorePages(),
'next_page' => $tribunals->hasMorePages() ? $tribunals->currentPage() + 1 : null,
]);
}
public function show(Tribunal $tribunal)
{
$tribunal->load(['anime:id,title,slug,cover_image', 'creator:id,name,username']);
$me = Auth::id();
$sides = $tribunal->allSides();
$vcounts = [];
foreach (array_keys($sides) as $key) {
$vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
}
$myVote = $me ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') : null;
$myArg = $me ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() : null;
$arguments = TribunalArgument::with('user:id,name,username')
->where('tribunal_id', $tribunal->id)
->orderByDesc('vote_count')
->get()
->map(fn($a) => [
'id' => $a->id,
'side' => $a->side,
'body' => $a->body,
'vote_count' => $a->vote_count,
'username' => $a->user?->username,
'is_mine' => $me && $a->user_id === $me,
'voted' => $me ? TribunalArgumentVote::where('argument_id', $a->id)->where('user_id', $me)->exists() : false,
]);
return response()->json([
'tribunal' => $this->formatTribunal($tribunal),
'sides' => $sides,
'vote_counts' => $vcounts,
'total_votes' => array_sum($vcounts),
'my_vote' => $myVote,
'my_argument' => $myArg ? ['id' => $myArg->id, 'side' => $myArg->side, 'body' => $myArg->body] : null,
'arguments' => $arguments,
]);
}
public function store(Request $request)
{
$data = $request->validate([
'anime_id' => 'required|exists:animes,id',
'question' => 'required|string|min:10|max:280',
'side_a' => 'required|string|min:2|max:100',
'side_b' => 'required|string|min:2|max:100',
'extra_sides' => 'nullable|array|max:4',
'extra_sides.*'=> 'required|string|min:2|max:100',
'closes_at' => 'nullable|date|after:today',
]);
$me = Auth::user();
$tribunal = Tribunal::create([
'anime_id' => $data['anime_id'],
'created_by' => $me->id,
'question' => $data['question'],
'side_a' => $data['side_a'],
'side_b' => $data['side_b'],
'extra_sides' => $data['extra_sides'] ?? [],
'status' => 'open',
'closes_at' => $data['closes_at'] ?? now()->addDays(7),
]);
return response()->json(['ok' => true, 'id' => $tribunal->id]);
}
public function vote(Request $request, Tribunal $tribunal)
{
if ($tribunal->status !== 'open') {
return response()->json(['error' => 'Bu dava kapalı.'], 422);
}
$sides = array_keys($tribunal->allSides());
$data = $request->validate(['side' => 'required|in:' . implode(',', $sides)]);
$me = Auth::id();
$existing = TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first();
if ($existing) {
if ($existing->side === $data['side']) {
$existing->delete();
$voted = null;
} else {
$existing->update(['side' => $data['side']]);
$voted = $data['side'];
}
} else {
TribunalVote::create(['tribunal_id' => $tribunal->id, 'user_id' => $me, 'side' => $data['side']]);
$voted = $data['side'];
}
$vcounts = [];
foreach (array_keys($tribunal->allSides()) as $key) {
$vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count();
}
return response()->json(['voted' => $voted, 'vote_counts' => $vcounts, 'total_votes' => array_sum($vcounts)]);
}
public function argue(Request $request, Tribunal $tribunal)
{
if ($tribunal->status !== 'open') {
return response()->json(['error' => 'Bu dava kapalı.'], 422);
}
$sides = array_keys($tribunal->allSides());
$data = $request->validate([
'side' => 'required|in:' . implode(',', $sides),
'body' => 'required|string|min:5|max:500',
]);
$me = Auth::user();
$existing = TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me->id)->first();
if ($existing) {
$existing->update(['side' => $data['side'], 'body' => $data['body']]);
return response()->json(['ok' => true, 'id' => $existing->id, 'updated' => true]);
}
$arg = TribunalArgument::create([
'tribunal_id' => $tribunal->id,
'user_id' => $me->id,
'side' => $data['side'],
'body' => $data['body'],
]);
return response()->json(['ok' => true, 'id' => $arg->id, 'updated' => false]);
}
public function argVote(TribunalArgument $argument)
{
$me = Auth::id();
$existing = TribunalArgumentVote::where('argument_id', $argument->id)->where('user_id', $me)->first();
if ($existing) {
$existing->delete();
$argument->decrement('vote_count');
return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]);
}
TribunalArgumentVote::create(['argument_id' => $argument->id, 'user_id' => $me]);
$argument->increment('vote_count');
return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]);
}
private function formatTribunal(Tribunal $t): array
{
return [
'id' => $t->id,
'question' => $t->question,
'status' => $t->status,
'sides' => $t->allSides(),
'votes_count' => $t->votes_count ?? TribunalVote::where('tribunal_id', $t->id)->count(),
'closes_at' => $t->closes_at?->toIso8601String(),
'created_at' => $t->created_at->diffForHumans(),
'anime' => $t->anime ? [
'id' => $t->anime->id,
'title' => $t->anime->title,
'slug' => $t->anime->slug,
'cover' => $t->anime->cover_image ? MediaUrl::fromStoragePath($t->anime->cover_image) : null,
] : null,
'creator' => $t->creator ? [
'name' => $t->creator->name,
'username' => $t->creator->username,
] : null,
];
}
}
@@ -0,0 +1,300 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Anime;
use App\Models\AnimeRating;
use App\Models\AnimeFollow;
use App\Models\ContinueWatching;
use App\Models\EpisodeVote;
use App\Models\EpisodeNote;
use App\Models\UserNotification;
use App\Models\UserAchievement;
use App\Models\Achievement;
use App\Models\Watchlist;
use App\Models\AnimeRequest;
use App\Models\AnimeRequestVote;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UserApiController extends Controller
{
// ── Watchlist ──────────────────────────────────────────────────────────────
public function watchlist(Request $request)
{
$status = $request->input('status'); // watching|completed|plan_to_watch|dropped
$query = Watchlist::where('user_id', $request->user()->id)
->with('anime:id,title,slug,cover_image,rating,episode_count,status,release_year');
if ($status) $query->where('status', $status);
$items = $query->orderByDesc('updated_at')->paginate(24);
return response()->json([
'data' => collect($items->items())->map(fn($w) => [
'id' => $w->id,
'status' => $w->status,
'anime' => $w->anime ? [
'id' => $w->anime->id,
'title' => $w->anime->title,
'slug' => $w->anime->slug,
'cover_url' => \App\Support\MediaUrl::fromStoragePath($w->anime->cover_image),
'rating' => $w->anime->rating,
'episode_count' => $w->anime->episode_count,
'status' => $w->anime->status,
'release_year' => $w->anime->release_year,
] : null,
]),
'total' => $items->total(),
'last_page'=> $items->lastPage(),
]);
}
public function watchlistToggle(Request $request, Anime $anime)
{
$user = $request->user();
$status = $request->input('status', 'plan_to_watch');
$existing = Watchlist::where('user_id', $user->id)->where('anime_id', $anime->id)->first();
if ($existing) {
if ($existing->status === $status) {
$existing->delete();
return response()->json(['in_watchlist' => false, 'status' => null]);
}
$existing->update(['status' => $status]);
return response()->json(['in_watchlist' => true, 'status' => $status]);
}
Watchlist::create(['user_id' => $user->id, 'anime_id' => $anime->id, 'status' => $status]);
return response()->json(['in_watchlist' => true, 'status' => $status]);
}
// ── Continue Watching ──────────────────────────────────────────────────────
public function continueWatchingUpdate(Request $request)
{
$data = $request->validate([
'anime_id' => 'required|exists:animes,id',
'season_number' => 'required|integer|min:1',
'episode_number' => 'required|integer|min:1',
'percent_complete' => 'required|numeric|min:0|max:100',
]);
ContinueWatching::updateOrCreate(
['user_id' => $request->user()->id, 'anime_id' => $data['anime_id']],
[
'season_number' => $data['season_number'],
'episode_number' => $data['episode_number'],
'percent_complete' => $data['percent_complete'],
]
);
return response()->json(['ok' => true]);
}
// ── Anime Rate ─────────────────────────────────────────────────────────────
public function animeRate(Request $request, Anime $anime)
{
$data = $request->validate(['rating' => 'required|numeric|min:1|max:10']);
AnimeRating::updateOrCreate(
['user_id' => $request->user()->id, 'anime_id' => $anime->id],
['rating' => $data['rating']]
);
$avg = AnimeRating::where('anime_id', $anime->id)->avg('rating');
$anime->update(['rating' => round($avg, 1)]);
return response()->json(['rating' => $data['rating'], 'avg' => round($avg, 1)]);
}
// ── Follow ─────────────────────────────────────────────────────────────────
public function followToggle(Request $request, Anime $anime)
{
$user = $request->user();
$existing = AnimeFollow::where('user_id', $user->id)->where('anime_id', $anime->id)->first();
if ($existing) {
$existing->delete();
return response()->json(['following' => false]);
}
AnimeFollow::create(['user_id' => $user->id, 'anime_id' => $anime->id]);
return response()->json(['following' => true]);
}
// ── Notifications ──────────────────────────────────────────────────────────
public function notifications(Request $request)
{
$items = UserNotification::where('user_id', $request->user()->id)
->orderByDesc('created_at')->paginate(20);
// Mark all as read
UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
return response()->json([
'data' => collect($items->items())->map(fn($n) => [
'id' => $n->id,
'type' => $n->type,
'data' => $n->data ?? [],
'is_read' => $n->is_read,
'created_at' => $n->created_at?->diffForHumans(),
]),
'total' => $items->total(),
'last_page'=> $items->lastPage(),
]);
}
public function notificationsCount(Request $request)
{
if (!$request->user()) return response()->json(['count' => 0]);
$count = UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->count();
return response()->json(['count' => $count]);
}
// ── Achievements ───────────────────────────────────────────────────────────
public function achievements(Request $request)
{
$all = Achievement::orderBy('points')->get();
$earned = UserAchievement::where('user_id', $request->user()->id)->pluck('achievement_id')->toArray();
$total = UserAchievement::where('user_id', $request->user()->id)->join('achievements','achievements.id','=','user_achievements.achievement_id')->sum('achievements.points');
return response()->json([
'total_points' => (int)$total,
'data' => $all->map(fn($a) => [
'id' => $a->id,
'name' => $a->name,
'description' => $a->description,
'icon' => $a->icon,
'points' => $a->points,
'earned' => in_array($a->id, $earned),
'earned_at' => in_array($a->id, $earned)
? UserAchievement::where('user_id', $request->user()->id)->where('achievement_id', $a->id)->value('created_at')?->toISOString()
: null,
]),
]);
}
// ── Episode Notes ──────────────────────────────────────────────────────────
public function noteStore(Request $request, $episodeId)
{
$data = $request->validate(['note' => 'required|string|max:1000']);
$note = EpisodeNote::create([
'user_id' => $request->user()->id,
'episode_id' => $episodeId,
'note' => $data['note'],
]);
return response()->json(['id' => $note->id, 'note' => $note->note, 'created_at' => $note->created_at?->toISOString()], 201);
}
public function noteDelete(Request $request, $noteId)
{
$note = EpisodeNote::where('id', $noteId)->where('user_id', $request->user()->id)->firstOrFail();
$note->delete();
return response()->json(['ok' => true]);
}
public function episodeNotesList(Request $request, $episodeId)
{
$notes = EpisodeNote::where('user_id', $request->user()->id)
->where('episode_id', $episodeId)
->orderByDesc('created_at')->get();
return response()->json($notes->map(fn($n) => [
'id' => $n->id,
'note' => $n->note,
'created_at' => $n->created_at?->toISOString(),
]));
}
// ── Anime Requests ─────────────────────────────────────────────────────────
public function requestIndex(Request $request)
{
$items = AnimeRequest::withCount('votes')
->orderByDesc('votes_count')->orderByDesc('created_at')->paginate(20);
return response()->json([
'data' => collect($items->items())->map(fn($r) => [
'id' => $r->id,
'title' => $r->title,
'note' => $r->note,
'status' => $r->status,
'votes_count' => $r->votes_count,
'created_at' => $r->created_at?->diffForHumans(),
'user_voted' => $request->user()
? AnimeRequestVote::where('user_id', $request->user()->id)->where('anime_request_id', $r->id)->exists()
: false,
]),
'total' => $items->total(),
'last_page'=> $items->lastPage(),
]);
}
public function requestStore(Request $request)
{
$data = $request->validate([
'title' => 'required|string|max:200',
'note' => 'nullable|string|max:500',
]);
$req = AnimeRequest::create([
'user_id' => $request->user()->id,
'title' => $data['title'],
'note' => $data['note'] ?? null,
'status' => 'pending',
]);
return response()->json(['id' => $req->id, 'title' => $req->title], 201);
}
public function requestVote(Request $request, AnimeRequest $animeRequest)
{
$user = $request->user();
$existing = AnimeRequestVote::where('user_id', $user->id)->where('anime_request_id', $animeRequest->id)->first();
if ($existing) {
$existing->delete();
return response()->json(['voted' => false, 'votes' => $animeRequest->votes()->count()]);
}
AnimeRequestVote::create(['user_id' => $user->id, 'anime_request_id' => $animeRequest->id]);
return response()->json(['voted' => true, 'votes' => $animeRequest->votes()->count()]);
}
// ── Profile Stats ──────────────────────────────────────────────────────────
public function profileStats(Request $request)
{
$userId = $request->user()->id;
$watchlistCount = Watchlist::where('user_id', $userId)->count();
$completedCount = Watchlist::where('user_id', $userId)->where('status', 'completed')->count();
$notifCount = UserNotification::where('user_id', $userId)->where('is_read', false)->count();
$achPoints = UserAchievement::where('user_id', $userId)
->join('achievements','achievements.id','=','user_achievements.achievement_id')
->sum('achievements.points');
$achCount = UserAchievement::where('user_id', $userId)->count();
$commentCount = \App\Models\Comment::where('user_id', $userId)->count();
return response()->json([
'watchlist_count' => $watchlistCount,
'completed_count' => $completedCount,
'notif_count' => (int)$notifCount,
'achievement_points'=> (int)$achPoints,
'achievement_count' => $achCount,
'comment_count' => $commentCount,
]);
}
}