Compare commits
No commits in common. "e0aa6e481778a06b13e7d6277a551bc25e49ab77" and "da714958c6a492b0798f97bf9dd3acb7ffaa76c1" have entirely different histories.
e0aa6e4817
...
da714958c6
@ -1206,170 +1206,9 @@ class VideoController extends Controller
|
|||||||
unset($data['visibility']);
|
unset($data['visibility']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Optional main media file replacement + type conversion ──────────
|
|
||||||
// When the edit form ships a new `video` file, replace the primary
|
|
||||||
// media. If the new file is audio, force type=music. When the type
|
|
||||||
// folder changes (generic → music, etc.), relocate the file on NAS
|
|
||||||
// into the canonical per-type layout.
|
|
||||||
if ($request->hasFile('video')) {
|
|
||||||
$request->validate([
|
|
||||||
'video' => 'file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$newFile = $request->file('video');
|
|
||||||
$mimeType = $newFile->getMimeType();
|
|
||||||
$isAudio = str_starts_with((string) $mimeType, 'audio/');
|
|
||||||
$newSize = $newFile->getSize();
|
|
||||||
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
|
||||||
$nas = app(\App\Services\NasSyncService::class);
|
|
||||||
|
|
||||||
// Desired type — form value wins, but audio always forces music.
|
|
||||||
$desiredType = $data['type'] ?? $video->type ?? 'generic';
|
|
||||||
if ($isAudio) $desiredType = 'music';
|
|
||||||
$data['type'] = $desiredType;
|
|
||||||
|
|
||||||
$oldPath = (string) $video->path;
|
|
||||||
$oldVideoDirNas = str_starts_with($oldPath, 'users/')
|
|
||||||
? implode('/', array_slice(explode('/', $oldPath), 0, 4))
|
|
||||||
: null;
|
|
||||||
// Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…".
|
|
||||||
// We must compare against this (not the DB type) because a prior type
|
|
||||||
// edit can leave a video with type=music but files still under videos/.
|
|
||||||
$oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null;
|
|
||||||
|
|
||||||
// Mutate in-memory so typeFolder / resolveVideoDir react to the new type
|
|
||||||
$video->type = $desiredType;
|
|
||||||
// For music, the primary track folder is {lang}-{id} — pick up any
|
|
||||||
// language the same form is about to save so the folder is right.
|
|
||||||
if (array_key_exists('language', $data)) {
|
|
||||||
$video->language = $data['language'];
|
|
||||||
}
|
|
||||||
$needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video);
|
|
||||||
|
|
||||||
// Clear old HLS (bytes + DB flags reset below)
|
|
||||||
if ($video->has_hls && $video->hls_path) {
|
|
||||||
\Storage::deleteDirectory($video->hls_path);
|
|
||||||
}
|
|
||||||
$localHlsAbs = storage_path('app/public/hls/' . $video->id);
|
|
||||||
if (is_dir($localHlsAbs)) @exec('rm -rf ' . escapeshellarg($localHlsAbs));
|
|
||||||
|
|
||||||
// Stash the uploaded file to a temp location
|
|
||||||
$tempFilename = \Str::uuid() . '.' . $newExt;
|
|
||||||
$newFile->storeAs('public/tmp', $tempFilename);
|
|
||||||
$tempAbs = storage_path('app/public/tmp/' . $tempFilename);
|
|
||||||
|
|
||||||
if (! file_exists($tempAbs)) {
|
|
||||||
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ffprobe ─────────────────────────────────────────────────────
|
|
||||||
$width = $height = null;
|
|
||||||
$orientation = 'landscape';
|
|
||||||
$duration = 0;
|
|
||||||
try {
|
|
||||||
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
|
||||||
$out = [];
|
|
||||||
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbs), $out);
|
|
||||||
$duration = (int) round((float) ($out[0] ?? 0));
|
|
||||||
if (! $isAudio) {
|
|
||||||
$ffprobe = \FFMpeg\FFProbe::create();
|
|
||||||
$stream = $ffprobe->streams($tempAbs)->videos()->first();
|
|
||||||
if ($stream) {
|
|
||||||
$width = $stream->get('width');
|
|
||||||
$height = $stream->get('height');
|
|
||||||
if ($width && $height) {
|
|
||||||
if ($height > $width) $orientation = 'portrait';
|
|
||||||
elseif ($width > $height) $orientation = 'landscape';
|
|
||||||
else $orientation = 'square';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
\Log::warning('update: FFprobe on replacement file failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prep video for uploadDirectToNas — filename drives ext detection,
|
|
||||||
// clearing path when relocating forces a fresh dir under the new type.
|
|
||||||
$video->filename = 'placeholder.' . $newExt;
|
|
||||||
if ($needsRelocate) $video->path = null;
|
|
||||||
|
|
||||||
$nasReplaceSucceeded = false;
|
|
||||||
if ($nas->isEnabled()) {
|
|
||||||
try {
|
|
||||||
$nas->uploadDirectToNas($video, $tempAbs, null);
|
|
||||||
$video->refresh();
|
|
||||||
// Re-apply the in-memory type mutation lost by refresh()
|
|
||||||
$video->type = $desiredType;
|
|
||||||
$nasReplaceSucceeded = true;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
\Log::error('update: NAS media replace failed: ' . $e->getMessage());
|
|
||||||
// Fall through — file stays in /tmp; DB state below still lands
|
|
||||||
$video->update([
|
|
||||||
'path' => 'public/tmp/' . $tempFilename,
|
|
||||||
'filename' => $tempFilename,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$video->update([
|
|
||||||
'path' => 'public/tmp/' . $tempFilename,
|
|
||||||
'filename' => $tempFilename,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup: delete OLD primary file + meta.json from NAS (only when
|
|
||||||
// it's on NAS AND the new path is different). Leave the old thumbnail
|
|
||||||
// in place so it keeps serving until the user uploads a new one.
|
|
||||||
if ($nasReplaceSucceeded && $oldPath && str_starts_with($oldPath, 'users/') && $oldPath !== $video->path) {
|
|
||||||
try { $nas->deleteFile($oldPath); } catch (\Throwable $e) {}
|
|
||||||
if ($needsRelocate && $oldVideoDirNas) {
|
|
||||||
$newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4));
|
|
||||||
if ($oldVideoDirNas !== $newVideoDirNas) {
|
|
||||||
// Old meta.json must go so resolveVideoDir doesn't get
|
|
||||||
// confused by two folders claiming the same video id.
|
|
||||||
try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wipe local cache from the OLD dir (regenerable content only)
|
|
||||||
if ($oldVideoDirNas) {
|
|
||||||
$oldLocalCache = storage_path('app/' . $oldVideoDirNas . '/cache');
|
|
||||||
if (is_dir($oldLocalCache)) @exec('rm -rf ' . escapeshellarg($oldLocalCache));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bake metadata into $data so the trailing $video->update($data) applies it
|
|
||||||
$data['size'] = $newSize;
|
|
||||||
$data['mime_type'] = $mimeType;
|
|
||||||
$data['has_hls'] = false;
|
|
||||||
$data['hls_path'] = null;
|
|
||||||
$data['status'] = 'ready';
|
|
||||||
$data['slideshow_video_path'] = null;
|
|
||||||
if (! $isAudio) {
|
|
||||||
$data['duration'] = $duration ?: $video->duration;
|
|
||||||
$data['width'] = $width ?: $video->width;
|
|
||||||
$data['height'] = $height ?: $video->height;
|
|
||||||
$data['orientation'] = $orientation;
|
|
||||||
$data['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
|
|
||||||
} else {
|
|
||||||
$data['duration'] = $duration ?: $video->duration;
|
|
||||||
$data['width'] = null;
|
|
||||||
$data['height'] = null;
|
|
||||||
$data['orientation'] = 'landscape';
|
|
||||||
$data['is_shorts'] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $isAudio) {
|
|
||||||
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
|
|
||||||
->onQueue('video-processing')
|
|
||||||
->onConnection('database');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle slide reorder / removal / additions for audio tracks
|
// Handle slide reorder / removal / additions for audio tracks
|
||||||
// Gate on declared type, not the file extension — a video row can be
|
|
||||||
// type=music while its primary file is still an mp4 (mid-conversion).
|
|
||||||
$slidesChanged = false;
|
$slidesChanged = false;
|
||||||
if (($data['type'] ?? $video->type) === 'music') {
|
if ($this->isAudioOnlyFile($video)) {
|
||||||
// slides_order is a JSON array of kept slide IDs in their new order
|
// slides_order is a JSON array of kept slide IDs in their new order
|
||||||
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
||||||
|
|
||||||
@ -1456,8 +1295,7 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio track management (delete + add) ────────────────────────────
|
// ── Audio track management (delete + add) ────────────────────────────
|
||||||
// Gate on declared type, not extension (see slide gate above).
|
if ($this->isAudioOnlyFile($video)) {
|
||||||
if (($data['type'] ?? $video->type) === 'music') {
|
|
||||||
$nas = app(\App\Services\NasSyncService::class);
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
// Delete tracks requested for removal
|
// Delete tracks requested for removal
|
||||||
@ -1750,19 +1588,6 @@ class VideoController extends Controller
|
|||||||
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
||||||
$nas = app(\App\Services\NasSyncService::class);
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
// When the replacement is audio, the video must become type=music so the
|
|
||||||
// NAS layout / player pipeline agree with the file. Force it and relocate.
|
|
||||||
$desiredType = $isAudio ? 'music' : $video->type;
|
|
||||||
$oldPath = (string) $video->path;
|
|
||||||
$oldVideoDirNas = str_starts_with($oldPath, 'users/')
|
|
||||||
? implode('/', array_slice(explode('/', $oldPath), 0, 4))
|
|
||||||
: null;
|
|
||||||
// Actual on-disk type folder is segment index 2 of "users/{slug}/{type}/…".
|
|
||||||
// Compare against this (not DB type) so a stale type edit can still relocate.
|
|
||||||
$oldPathTypeFolder = $oldVideoDirNas ? explode('/', $oldVideoDirNas)[2] ?? null : null;
|
|
||||||
$video->type = $desiredType;
|
|
||||||
$needsRelocate = $oldPathTypeFolder !== null && $oldPathTypeFolder !== $nas->typeFolder($video);
|
|
||||||
|
|
||||||
// ── 1. Clear old HLS ─────────────────────────────────────────────────
|
// ── 1. Clear old HLS ─────────────────────────────────────────────────
|
||||||
if ($video->has_hls && $video->hls_path) {
|
if ($video->has_hls && $video->hls_path) {
|
||||||
\Storage::deleteDirectory($video->hls_path);
|
\Storage::deleteDirectory($video->hls_path);
|
||||||
@ -1832,24 +1657,13 @@ class VideoController extends Controller
|
|||||||
if ($nas->isEnabled()) {
|
if ($nas->isEnabled()) {
|
||||||
|
|
||||||
// Point filename at the new file (uploadDirectToNas uses this for ext)
|
// Point filename at the new file (uploadDirectToNas uses this for ext)
|
||||||
$update = ['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize, 'type' => $desiredType];
|
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
|
||||||
if ($needsRelocate) $update['path'] = null; // force resolveVideoDir to pick a fresh type-appropriate dir
|
|
||||||
$video->update($update);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pass null for thumb — we don't want to overwrite the existing thumbnail
|
// Pass null for thumb — we don't want to overwrite the existing thumbnail
|
||||||
$nas->uploadDirectToNas($video, $tempAbsPath, null);
|
$nas->uploadDirectToNas($video, $tempAbsPath, null);
|
||||||
$video->refresh();
|
$video->refresh();
|
||||||
$nasReplaceSucceeded = true;
|
$nasReplaceSucceeded = true;
|
||||||
|
|
||||||
// Clean up the old NAS folder's meta.json if we relocated (so
|
|
||||||
// resolveVideoDir isn't confused by two folders claiming this id).
|
|
||||||
if ($needsRelocate && $oldVideoDirNas) {
|
|
||||||
$newVideoDirNas = implode('/', array_slice(explode('/', (string) $video->path), 0, 4));
|
|
||||||
if ($oldVideoDirNas !== $newVideoDirNas) {
|
|
||||||
try { $nas->deleteFile("{$oldVideoDirNas}/meta.json"); } catch (\Throwable $e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
|
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
|
||||||
// Temp file still exists — fall through to organizeLocalFiles below
|
// Temp file still exists — fall through to organizeLocalFiles below
|
||||||
|
|||||||
@ -1198,15 +1198,12 @@
|
|||||||
}
|
}
|
||||||
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
||||||
.yt-main.video-view-page { padding: 0 !important; }
|
.yt-main.video-view-page { padding: 0 !important; }
|
||||||
.yt-filter-bar-wrap {
|
.yt-filter-bar {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
top: auto !important;
|
top: auto !important;
|
||||||
z-index: auto !important;
|
z-index: auto !important;
|
||||||
margin: -16px -16px 16px !important;
|
margin: -16px -16px 16px !important;
|
||||||
}
|
}
|
||||||
.yt-filter-bar {
|
|
||||||
padding: 12px 16px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -108,7 +108,6 @@
|
|||||||
<div class="um-track-card" id="edit-tc-t1-card">
|
<div class="um-track-card" id="edit-tc-t1-card">
|
||||||
<div class="um-tc-body">
|
<div class="um-tc-body">
|
||||||
<div class="um-tc-left">
|
<div class="um-tc-left">
|
||||||
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
|
|
||||||
<div class="um-tc-num">1</div>
|
<div class="um-tc-num">1</div>
|
||||||
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
|
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
|
||||||
<div class="um-tc-info">
|
<div class="um-tc-info">
|
||||||
@ -117,6 +116,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="um-tc-right">
|
<div class="um-tc-right">
|
||||||
|
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('t1-card','up')" title="Move up" style="display:none;">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('t1-card','down')" title="Move down" style="display:none;">
|
||||||
|
<i class="bi bi-arrow-down"></i>
|
||||||
|
</button>
|
||||||
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
|
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
|
||||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
@ -198,24 +203,6 @@
|
|||||||
<style>
|
<style>
|
||||||
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
||||||
#editVideoModal.show .modal-dialog { opacity: 1; }
|
#editVideoModal.show .modal-dialog { opacity: 1; }
|
||||||
|
|
||||||
/* ── Track drag-and-drop reorder ─────────────────────────────── */
|
|
||||||
#edit-tc-list .um-track-card { position: relative; }
|
|
||||||
#edit-tc-list .um-tc-drag {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; margin-right: 2px;
|
|
||||||
color: #666; cursor: grab; user-select: none;
|
|
||||||
border-radius: 6px; transition: color .15s, background .15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
#edit-tc-list .um-tc-drag:hover { color: #e5e5e5; background: rgba(255,255,255,.05); }
|
|
||||||
#edit-tc-list .um-tc-drag:active { cursor: grabbing; }
|
|
||||||
#edit-tc-list .um-tc-drag i { font-size: 14px; line-height: 1; }
|
|
||||||
#edit-tc-list .um-track-card.dragging {
|
|
||||||
opacity: .4; outline: 2px dashed #e61e1e; outline-offset: -2px;
|
|
||||||
}
|
|
||||||
#edit-tc-list .um-track-card.drop-before { box-shadow: 0 -3px 0 0 #e61e1e inset; }
|
|
||||||
#edit-tc-list .um-track-card.drop-after { box-shadow: 0 3px 0 0 #e61e1e inset; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -301,9 +288,8 @@ function openEditVideoModal(videoId) {
|
|||||||
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
|
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
|
||||||
_editRenderSlides('t1');
|
_editRenderSlides('t1');
|
||||||
|
|
||||||
// Extra tracks — show whenever the row is declared music, even if the
|
// Extra tracks
|
||||||
// primary file is still a video (mid-conversion state).
|
if (v.is_audio && v.audio_tracks) {
|
||||||
if ((v.type === 'music' || v.is_audio) && v.audio_tracks) {
|
|
||||||
v.audio_tracks.forEach(track => _editAddExistingTrack(track));
|
v.audio_tracks.forEach(track => _editAddExistingTrack(track));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -460,16 +446,33 @@ function _editFmtSize(bytes) {
|
|||||||
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Track ordering (drag-and-drop) ────────────────────────────────────────────
|
// ── Track ordering ────────────────────────────────────────────────────────────
|
||||||
|
function editMoveTrack(cardSuffix, dir) {
|
||||||
|
const card = document.getElementById('edit-tc-' + cardSuffix);
|
||||||
|
if (!card) return;
|
||||||
|
const list = document.getElementById('edit-tc-list');
|
||||||
|
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
||||||
|
const idx = cards.indexOf(card);
|
||||||
|
console.log('%c[EditTrack] Reorder:', 'color:#3b82f6', { card: card.id, trackId: card.dataset.trackId || 'primary', direction: dir, fromPos: idx + 1 });
|
||||||
|
if (dir === 'up' && idx > 0) list.insertBefore(card, cards[idx - 1]);
|
||||||
|
if (dir === 'down' && idx < cards.length - 1) list.insertBefore(cards[idx + 1], card);
|
||||||
|
_editUpdateTrackPositions();
|
||||||
|
}
|
||||||
|
|
||||||
function _editUpdateTrackPositions() {
|
function _editUpdateTrackPositions() {
|
||||||
const list = document.getElementById('edit-tc-list');
|
const list = document.getElementById('edit-tc-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
||||||
|
const total = cards.length;
|
||||||
cards.forEach((card, i) => {
|
cards.forEach((card, i) => {
|
||||||
const numEl = card.querySelector('.um-tc-num');
|
const numEl = card.querySelector('.um-tc-num');
|
||||||
if (numEl) numEl.textContent = i + 1;
|
if (numEl) numEl.textContent = i + 1;
|
||||||
const badge = card.querySelector('.um-tc-primary');
|
const badge = card.querySelector('.um-tc-primary');
|
||||||
if (badge) badge.style.display = i === 0 ? '' : 'none';
|
if (badge) badge.style.display = i === 0 ? '' : 'none';
|
||||||
|
const up = card.querySelector('.edit-tc-arrow-up');
|
||||||
|
const down = card.querySelector('.edit-tc-arrow-down');
|
||||||
|
if (up) up.style.display = (i === 0) ? 'none' : '';
|
||||||
|
if (down) down.style.display = (i === total - 1) ? 'none' : '';
|
||||||
});
|
});
|
||||||
// Set promote_track_id: non-empty only when a secondary is at position 1
|
// Set promote_track_id: non-empty only when a secondary is at position 1
|
||||||
const first = cards[0];
|
const first = cards[0];
|
||||||
@ -482,93 +485,6 @@ function _editUpdateTrackPositions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML5 drag-and-drop: bind once on the shared parent so newly appended cards
|
|
||||||
// pick it up without extra wiring. The card starts non-draggable so text
|
|
||||||
// selection / button clicks work; grabbing the grip handle flips draggable=on
|
|
||||||
// for the duration of the drag.
|
|
||||||
(function () {
|
|
||||||
const list = document.getElementById('edit-tc-list');
|
|
||||||
if (!list || list.dataset.dndWired) return;
|
|
||||||
list.dataset.dndWired = '1';
|
|
||||||
|
|
||||||
// Reset any draggable="true" set by the initial HTML — we only enable it
|
|
||||||
// on demand via the grip handle.
|
|
||||||
list.querySelectorAll('.um-track-card').forEach(c => c.removeAttribute('draggable'));
|
|
||||||
|
|
||||||
let dragged = null;
|
|
||||||
|
|
||||||
list.addEventListener('mousedown', function (e) {
|
|
||||||
const handle = e.target.closest('.um-tc-drag');
|
|
||||||
if (!handle) return;
|
|
||||||
const card = handle.closest('.um-track-card');
|
|
||||||
if (!card || card.parentElement !== list) return;
|
|
||||||
card.setAttribute('draggable', 'true');
|
|
||||||
});
|
|
||||||
list.addEventListener('mouseup', function () {
|
|
||||||
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
|
|
||||||
});
|
|
||||||
|
|
||||||
list.addEventListener('dragstart', function (e) {
|
|
||||||
const card = e.target.closest('.um-track-card');
|
|
||||||
if (!card || card.parentElement !== list || card.getAttribute('draggable') !== 'true') {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dragged = card;
|
|
||||||
card.classList.add('dragging');
|
|
||||||
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', card.id); } catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
list.addEventListener('dragend', function () {
|
|
||||||
if (dragged) dragged.classList.remove('dragging');
|
|
||||||
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
|
||||||
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
|
|
||||||
dragged = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always accept drops anywhere inside the list so the browser doesn't
|
|
||||||
// veto the drop when the cursor slips into gaps between cards.
|
|
||||||
list.addEventListener('dragover', function (e) {
|
|
||||||
if (!dragged) return;
|
|
||||||
e.preventDefault();
|
|
||||||
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
|
|
||||||
const over = e.target.closest('.um-track-card');
|
|
||||||
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
|
||||||
if (!over || over === dragged || over.parentElement !== list) return;
|
|
||||||
const r = over.getBoundingClientRect();
|
|
||||||
const after = (e.clientY - r.top) > (r.height / 2);
|
|
||||||
over.classList.add(after ? 'drop-after' : 'drop-before');
|
|
||||||
});
|
|
||||||
|
|
||||||
list.addEventListener('drop', function (e) {
|
|
||||||
if (!dragged) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
|
||||||
const over = e.target.closest('.um-track-card');
|
|
||||||
let refNode;
|
|
||||||
if (over && over !== dragged && over.parentElement === list) {
|
|
||||||
const r = over.getBoundingClientRect();
|
|
||||||
const after = (e.clientY - r.top) > (r.height / 2);
|
|
||||||
refNode = after ? over.nextSibling : over;
|
|
||||||
} else {
|
|
||||||
// Cursor fell into a gap or outside a card — figure out the closest slot
|
|
||||||
// by comparing cursor Y against each card's midpoint.
|
|
||||||
let insertBefore = null;
|
|
||||||
for (const c of cards) {
|
|
||||||
if (c === dragged) continue;
|
|
||||||
const r = c.getBoundingClientRect();
|
|
||||||
if (e.clientY < r.top + r.height / 2) { insertBefore = c; break; }
|
|
||||||
}
|
|
||||||
refNode = insertBefore; // null → append to end
|
|
||||||
}
|
|
||||||
if (refNode === dragged) refNode = dragged.nextSibling;
|
|
||||||
list.insertBefore(dragged, refNode);
|
|
||||||
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
|
||||||
console.log('%c[EditTrack] Drag-reordered:', 'color:#3b82f6', { moved: dragged.id, refNode: refNode?.id || '(end)' });
|
|
||||||
_editUpdateTrackPositions();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
function _editSetLangCsd(wrap, code) {
|
function _editSetLangCsd(wrap, code) {
|
||||||
if (!wrap || !code) return;
|
if (!wrap || !code) return;
|
||||||
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
|
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
|
||||||
@ -947,7 +863,6 @@ function _editTrackCard(n, trackId, isExisting) {
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="um-tc-body">
|
<div class="um-tc-body">
|
||||||
<div class="um-tc-left">
|
<div class="um-tc-left">
|
||||||
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
|
|
||||||
<div class="um-tc-num">${n + 1}</div>
|
<div class="um-tc-num">${n + 1}</div>
|
||||||
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
|
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
|
||||||
<div class="um-tc-info">
|
<div class="um-tc-info">
|
||||||
@ -956,6 +871,8 @@ function _editTrackCard(n, trackId, isExisting) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="um-tc-right">
|
<div class="um-tc-right">
|
||||||
|
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('e${n}','up')" title="Move up" style="display:none;"><i class="bi bi-arrow-up"></i></button>
|
||||||
|
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('e${n}','down')" title="Move down" style="display:none;"><i class="bi bi-arrow-down"></i></button>
|
||||||
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
|
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user