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,488 @@
@extends('admin.layouts.app')
@section('title', 'Animeler')
@section('page-title', 'Animeler')
@push('styles')
<style>
.anime-cover {
width: 40px; height: 58px;
object-fit: cover;
border-radius: 6px;
background: var(--surface);
flex-shrink: 0;
}
.anime-cover-placeholder {
width: 40px; height: 58px;
border-radius: 6px;
background: var(--surface);
display: flex; align-items: center; justify-content: center;
color: var(--text3);
flex-shrink: 0;
font-size: 16px;
}
.anime-title { font-size: 13.5px; font-weight: 600; color: var(--text); }
.anime-genres { font-size: 12px; color: var(--text2); margin-top: 2px; }
.bulk-bar {
display: none;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--accent-dim);
border: 1px solid rgba(124,58,237,0.3);
border-radius: var(--radius-sm);
margin-bottom: 12px;
flex-wrap: wrap;
}
.bulk-bar.on { display: flex; }
.bulk-bar-count { font-size: 13px; font-weight: 700; color: var(--accent-lt); }
.mal-progress-card {
display: none;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 16px;
}
.mal-progress-card.on { display: block; }
.mal-log {
font-size: 12px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--text2);
max-height: 130px;
overflow-y: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-top: 10px;
line-height: 1.7;
}
/* Action dropdown */
.act-drop { position: relative; }
.act-drop-menu {
display: none;
position: absolute;
right: 0; top: calc(100% + 4px);
min-width: 160px;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
padding: 5px;
z-index: 200;
box-shadow: var(--shadow-lg);
}
.act-drop-menu.on { display: block; }
.act-drop-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 11px;
border-radius: 6px;
font-size: 13px;
color: var(--text2);
cursor: pointer;
text-decoration: none;
border: none;
background: none;
width: 100%;
text-align: left;
transition: all 0.12s;
font-family: inherit;
}
.act-drop-item:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.act-drop-item.danger:hover { background: var(--danger-dim); color: var(--danger); }
.act-drop-sep { height: 1px; background: var(--border); margin: 4px 0; }
</style>
@endpush
@section('content')
{{-- ── Page Header ── --}}
<div class="page-header">
<div>
<h5 style="font-size:18px;font-weight:700;color:var(--text);margin:0">
Animeler
<span style="font-size:13px;font-weight:600;color:var(--text2);margin-left:6px">{{ number_format($animes->total()) }} toplam</span>
</h5>
</div>
<div class="page-header-actions">
@if($zeroEpisodeCount > 0)
<button class="btn btn-outline-warning btn-sm" onclick="destroyZeroEpisodes()" id="btn-zero-ep">
<i class="bi bi-trash3"></i>
<span class="d-none d-md-inline">0 Bölümlü Sil</span>
<span class="badge bg-warning" style="color:#000">{{ $zeroEpisodeCount }}</span>
</button>
@endif
<button class="btn btn-secondary btn-sm" onclick="bulkFindMal()" id="btn-bulk-mal">
<i class="bi bi-magic"></i>
<span class="d-none d-md-inline">MAL ID Otomatik Bul</span>
</button>
<a href="{{ route('admin.animes.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i> Anime Ekle
</a>
</div>
</div>
{{-- ── MAL Progress Card ── --}}
<div class="mal-progress-card" id="bulk-mal-box">
<div class="d-flex align-items-center justify-content-between mb-3">
<div style="font-size:14px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px">
<i class="bi bi-magic" style="color:var(--info)"></i> MAL ID Otomatik Doldurma
</div>
<button class="btn btn-sm btn-outline-danger" onclick="stopBulkMal()"><i class="bi bi-stop-fill"></i> Durdur</button>
</div>
<div style="background:var(--bg);border-radius:99px;height:6px;overflow:hidden">
<div id="bulk-mal-bar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent-lt));border-radius:99px;transition:width .3s"></div>
</div>
<div class="mal-log" id="bulk-mal-log"></div>
</div>
{{-- ── Filters ── --}}
<div class="filter-card mb-3">
<form method="GET" class="d-flex flex-wrap gap-2 align-items-end">
<div class="search-input-wrap flex-grow-1" style="min-width:180px;max-width:320px">
<i class="bi bi-search"></i>
<input type="text" name="search" class="form-control" placeholder="Anime ara..." value="{{ request('search') }}">
</div>
<select name="status" class="form-select" style="width:auto;min-width:140px">
<option value="">Tüm Durumlar</option>
<option value="ongoing" {{ request('status')=='ongoing' ?'selected':'' }}>Devam Ediyor</option>
<option value="completed" {{ request('status')=='completed' ?'selected':'' }}>Tamamlandı</option>
<option value="upcoming" {{ request('status')=='upcoming' ?'selected':'' }}>Yakında</option>
</select>
<select name="type" class="form-select" style="width:auto;min-width:120px">
<option value="">Tüm Tipler</option>
<option value="series" {{ request('type')=='series'?'selected':'' }}>Dizi</option>
<option value="movie" {{ request('type')=='movie' ?'selected':'' }}>Film</option>
<option value="ova" {{ request('type')=='ova' ?'selected':'' }}>OVA</option>
</select>
<select name="no_episodes" class="form-select" style="width:auto;min-width:150px">
<option value="">Tüm Animeler</option>
<option value="1" {{ request('no_episodes')=='1'?'selected':'' }}>Sadece 0 Bölümlüler</option>
</select>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel"></i> Filtrele</button>
<a href="{{ route('admin.animes.index') }}" class="btn btn-secondary btn-sm"><i class="bi bi-x-lg"></i></a>
</div>
</form>
</div>
{{-- ── Bulk Selection Bar ── --}}
<div class="bulk-bar" id="bulk-bar">
<span class="bulk-bar-count" id="bulk-count">0 anime seçildi</span>
<span class="badge bg-info d-none" id="bulk-all-note" style="color:#000">Tüm sayfalardaki animeler</span>
<div style="margin-left:auto;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm btn-secondary d-none" id="btn-sel-page" onclick="selPage()">
<i class="bi bi-check2-square"></i> Sayfayı Seç
</button>
<button class="btn btn-sm btn-secondary d-none" id="btn-sel-all" onclick="selAllPages()">
<i class="bi bi-check2-all"></i> Tümünü Seç ({{ $animes->total() }})
</button>
<button class="btn btn-sm btn-outline-danger" onclick="bulkDelete()">
<i class="bi bi-trash3"></i> Seçilenleri Sil
</button>
<button class="btn btn-sm btn-secondary" onclick="clearSel()">
<i class="bi bi-x-lg"></i> İptal
</button>
</div>
</div>
{{-- ── Table ── --}}
<div class="table-wrap">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th style="width:44px;padding-left:20px">
<input type="checkbox" class="form-check-input" id="chk-page" onchange="togglePage(this.checked)">
</th>
<th>Anime</th>
<th>Tip / Durum</th>
<th>MAL ID</th>
<th style="text-align:center">Bölüm</th>
<th style="text-align:center">Yayın</th>
<th style="text-align:right;padding-right:20px">İşlem</th>
</tr>
</thead>
<tbody>
@forelse($animes as $anime)
<tr id="anime-row-{{ $anime->id }}">
<td style="padding-left:20px">
<input type="checkbox" class="form-check-input chk-item" value="{{ $anime->id }}" onchange="onCheck()">
</td>
<td>
<div class="d-flex align-items-center gap-3">
@if($anime->coverUrl)
<img src="{{ $anime->coverUrl }}" class="anime-cover" alt="">
@else
<div class="anime-cover-placeholder"><i class="bi bi-image"></i></div>
@endif
<div class="overflow-hidden">
<div class="anime-title text-truncate" style="max-width:260px">{{ $anime->title }}</div>
<div class="anime-genres">{{ $anime->genres->pluck('name')->join(', ') ?: '—' }}</div>
</div>
</div>
</td>
<td>
<div class="d-flex flex-column gap-1">
<span class="badge bg-secondary" style="width:fit-content">{{ strtoupper($anime->type) }}</span>
@if($anime->status === 'ongoing')
<span class="badge bg-success" style="width:fit-content">Devam Ediyor</span>
@elseif($anime->status === 'completed')
<span class="badge bg-info" style="width:fit-content;color:#000">Tamamlandı</span>
@else
<span class="badge bg-warning" style="width:fit-content;color:#000">Yakında</span>
@endif
</div>
</td>
<td class="mal-id-cell" data-id="{{ $anime->id }}" data-fetch-url="{{ route('admin.animes.fetch-mal', $anime) }}">
@if($anime->mal_id)
<a href="https://myanimelist.net/anime/{{ $anime->mal_id }}" target="_blank"
class="badge bg-success text-decoration-none mal-badge-{{ $anime->id }}" style="color:#fff">
<i class="bi bi-box-arrow-up-right" style="font-size:10px;margin-right:3px"></i>{{ $anime->mal_id }}
</a>
@else
<span class="mal-badge-{{ $anime->id }}" style="display:inline-flex;align-items:center;gap:5px">
<span class="badge bg-danger">Eksik</span>
<button class="btn btn-link btn-sm p-0" style="font-size:12px;color:var(--info)" title="Otomatik Bul"
onclick="fetchSingleMal({{ $anime->id }}, this)">
<i class="bi bi-magic"></i>
</button>
</span>
@endif
</td>
<td style="text-align:center">
<span style="font-size:14px;font-weight:700;color:{{ $anime->episode_count > 0 ? 'var(--text)' : 'var(--danger)' }}">
{{ $anime->episode_count }}
</span>
</td>
<td style="text-align:center">
@if($anime->is_published)
<span style="display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:var(--success)">
<i class="bi bi-circle-fill" style="font-size:7px"></i> Yayında
</span>
@else
<span style="display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:var(--text3)">
<i class="bi bi-circle" style="font-size:7px"></i> Taslak
</span>
@endif
</td>
<td style="text-align:right;padding-right:20px">
<div class="act-drop">
<button class="btn btn-secondary btn-sm" onclick="toggleDrop(this)">
<i class="bi bi-three-dots"></i>
</button>
<div class="act-drop-menu">
<a href="{{ route('admin.animes.show', $anime) }}" class="act-drop-item">
<i class="bi bi-eye"></i> Görüntüle
</a>
<a href="{{ route('admin.animes.edit', $anime) }}" class="act-drop-item">
<i class="bi bi-pencil"></i> Düzenle
</a>
<a href="{{ route('admin.episodes.index', ['anime_id' => $anime->id]) }}" class="act-drop-item">
<i class="bi bi-collection-play"></i> Bölümler
</a>
<div class="act-drop-sep"></div>
<form method="POST" action="{{ route('admin.animes.destroy', $anime) }}"
onsubmit="return confirm('{{ addslashes($anime->title) }} silinsin mi?')">
@csrf @method('DELETE')
<button type="submit" class="act-drop-item danger">
<i class="bi bi-trash3"></i> Sil
</button>
</form>
</div>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<h6>Anime bulunamadı</h6>
<p>Arama kriterlerinizi değiştirin veya yeni anime ekleyin.</p>
<a href="{{ route('admin.animes.create') }}" class="btn btn-primary btn-sm mt-2">
<i class="bi bi-plus-lg"></i> Anime Ekle
</a>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- ── Pagination ── --}}
<div class="mt-3">
{{ $animes->links('admin.partials.pagination') }}
</div>
@endsection
@push('scripts')
<script>
const BULK_MAL_URL = '{{ route("admin.animes.bulk-find-mal") }}';
const BULK_DESTROY_URL = '{{ route("admin.animes.bulk-destroy") }}';
const ZERO_DESTROY_URL = '{{ route("admin.animes.destroy-zero-episodes") }}';
const TOTAL_ANIMES = {{ $animes->total() }};
const CSRF = document.querySelector('meta[name=csrf-token]')?.content || '';
/* ── Action dropdown ── */
function toggleDrop(btn) {
const menu = btn.nextElementSibling;
const wasOn = menu.classList.contains('on');
document.querySelectorAll('.act-drop-menu.on').forEach(m => m.classList.remove('on'));
if (!wasOn) menu.classList.add('on');
}
document.addEventListener('click', e => {
if (!e.target.closest('.act-drop')) {
document.querySelectorAll('.act-drop-menu.on').forEach(m => m.classList.remove('on'));
}
});
/* ── Zero episode delete ── */
async function destroyZeroEpisodes() {
if (!confirm('0 bölümlü tüm animeler silinsin mi? Bu işlem geri alınamaz.')) return;
const btn = document.getElementById('btn-zero-ep');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try {
const res = await fetch(ZERO_DESTROY_URL, { method:'POST', headers:{'X-CSRF-TOKEN':CSRF,'Accept':'application/json'} });
const d = await res.json();
if (d.success) { axToast(`${d.count} anime silindi.`, 'success'); setTimeout(() => location.reload(), 1200); }
else { axToast('Hata oluştu.', 'error'); if (btn) btn.disabled = false; }
} catch(e) { axToast('Hata: ' + e.message, 'error'); if (btn) btn.disabled = false; }
}
/* ── Bulk selection ── */
let _selAll = false;
function _selBtns(show) {
document.getElementById('btn-sel-page').classList.toggle('d-none', !show);
document.getElementById('btn-sel-all').classList.toggle('d-none', !show);
}
function selPage() {
document.querySelectorAll('.chk-item').forEach(c => c.checked = true);
document.getElementById('chk-page').checked = true;
_selAll = false;
document.getElementById('bulk-all-note').classList.add('d-none');
onCheck();
}
function selAllPages() {
selPage(); _selAll = true;
document.getElementById('bulk-all-note').classList.remove('d-none');
updateBar();
}
function clearSel() {
document.querySelectorAll('.chk-item').forEach(c => c.checked = false);
document.getElementById('chk-page').checked = false;
_selAll = false;
document.getElementById('bulk-all-note').classList.add('d-none');
updateBar();
}
function togglePage(checked) {
document.querySelectorAll('.chk-item').forEach(c => c.checked = checked);
_selAll = false;
document.getElementById('bulk-all-note').classList.add('d-none');
onCheck();
}
function onCheck() {
const all = document.querySelectorAll('.chk-item');
const chk = document.querySelectorAll('.chk-item:checked');
document.getElementById('chk-page').checked = all.length > 0 && all.length === chk.length;
_selAll = false;
document.getElementById('bulk-all-note').classList.add('d-none');
updateBar();
}
function updateBar() {
const chk = document.querySelectorAll('.chk-item:checked');
const bar = document.getElementById('bulk-bar');
const cnt = document.getElementById('bulk-count');
if (_selAll) {
cnt.textContent = `Tüm ${TOTAL_ANIMES} anime seçildi`;
bar.classList.add('on'); _selBtns(false);
} else if (chk.length > 0) {
cnt.textContent = `${chk.length} anime seçildi`;
bar.classList.add('on'); _selBtns(true);
} else {
bar.classList.remove('on'); _selBtns(false);
}
}
async function bulkDelete() {
const chk = [...document.querySelectorAll('.chk-item:checked')].map(c => c.value);
const label = _selAll ? `tüm ${TOTAL_ANIMES} animeyi` : `${chk.length} animeyi`;
if (!confirm(`${label} silmek istediğinize emin misiniz?\nBu işlem CDN dosyalarını da siler!`)) return;
const params = new URLSearchParams(location.search);
const body = _selAll
? { all: true, filters: { search: params.get('search')||'', status: params.get('status')||'', type: params.get('type')||'' } }
: { ids: chk };
try {
const res = await fetch(BULK_DESTROY_URL, { method:'POST', headers:{'X-CSRF-TOKEN':CSRF,'Content-Type':'application/json','Accept':'application/json'}, body:JSON.stringify(body) });
const d = await res.json();
if (d.success) {
if (_selAll) { location.reload(); return; }
chk.forEach(id => document.getElementById(`anime-row-${id}`)?.remove());
clearSel();
axToast(`${chk.length} anime silindi.`, 'success');
} else { axToast('Hata: ' + (d.message || '?'), 'error'); }
} catch(e) { axToast('Hata: ' + e.message, 'error'); }
}
/* ── Bulk MAL find ── */
let bulkRunning = false, skipIds = [], totalDone = 0, totalInitial = 0;
async function bulkFindMal() {
if (bulkRunning) return;
bulkRunning = true; skipIds = []; totalDone = 0; totalInitial = 0;
document.getElementById('bulk-mal-box').classList.add('on');
document.getElementById('btn-bulk-mal').disabled = true;
document.getElementById('bulk-mal-log').innerHTML = '';
document.getElementById('bulk-mal-bar').style.width = '0%';
log('⏳ Başlatılıyor...');
await runNext();
}
async function runNext() {
if (!bulkRunning) return;
try {
const res = await fetch(BULK_MAL_URL, { method:'POST', headers:{'X-CSRF-TOKEN':CSRF,'Content-Type':'application/json','Accept':'application/json'}, body:JSON.stringify({skip_ids:skipIds}) });
const d = await res.json();
if (d.done) { log('✅ ' + d.message); finish(); return; }
totalDone++;
if (!totalInitial) totalInitial = totalDone + (d.remaining ?? 0);
const pct = totalInitial > 0 ? Math.round((totalDone / totalInitial) * 100) : 0;
document.getElementById('bulk-mal-bar').style.width = pct + '%';
if (d.found) log(`✓ <b>${esc(d.anime)}</b> → ${d.mal_id}`);
else { log(`<span style="opacity:.5">— ${esc(d.anime)}: bulunamadı</span>`); if (d.skipped_id) skipIds.push(d.skipped_id); }
setTimeout(runNext, 450);
} catch(e) { log(`<span style="color:var(--danger)">❌ ${e.message}</span>`); setTimeout(runNext, 2000); }
}
function stopBulkMal() { bulkRunning = false; log('⏹ Durduruldu.'); finish(); }
function finish() { bulkRunning = false; document.getElementById('btn-bulk-mal').disabled = false; document.getElementById('bulk-mal-bar').style.width = '100%'; }
function log(html) { const el = document.getElementById('bulk-mal-log'); el.innerHTML += html + '<br>'; el.scrollTop = el.scrollHeight; }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function fetchSingleMal(animeId, btn) {
const cell = btn.closest('.mal-id-cell');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:12px;height:12px;border-width:1.5px"></span>';
try {
const res = await fetch(cell.dataset.fetchUrl, { method:'POST', headers:{'X-CSRF-TOKEN':CSRF,'Accept':'application/json'} });
const d = await res.json();
if (d.found) {
cell.innerHTML = `<a href="https://myanimelist.net/anime/${d.mal_id}" target="_blank" class="badge bg-success text-decoration-none" style="color:#fff"><i class="bi bi-box-arrow-up-right" style="font-size:10px;margin-right:3px"></i>${d.mal_id}</a>`;
} else {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic"></i>';
btn.title = 'Bulunamadı — tekrar dene';
}
} catch(e) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-exclamation-triangle" style="color:var(--danger)"></i>'; }
}
</script>
@endpush