Compare commits
2 Commits
da714958c6
...
e0aa6e4817
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0aa6e4817 | ||
|
|
3d8d7b8efe |
@ -1206,9 +1206,170 @@ class VideoController extends Controller
|
||||
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
|
||||
// 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;
|
||||
if ($this->isAudioOnlyFile($video)) {
|
||||
if (($data['type'] ?? $video->type) === 'music') {
|
||||
// slides_order is a JSON array of kept slide IDs in their new order
|
||||
$keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];
|
||||
|
||||
@ -1295,7 +1456,8 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
// ── Audio track management (delete + add) ────────────────────────────
|
||||
if ($this->isAudioOnlyFile($video)) {
|
||||
// Gate on declared type, not extension (see slide gate above).
|
||||
if (($data['type'] ?? $video->type) === 'music') {
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
// Delete tracks requested for removal
|
||||
@ -1588,6 +1750,19 @@ class VideoController extends Controller
|
||||
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
||||
$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 ─────────────────────────────────────────────────
|
||||
if ($video->has_hls && $video->hls_path) {
|
||||
\Storage::deleteDirectory($video->hls_path);
|
||||
@ -1657,13 +1832,24 @@ class VideoController extends Controller
|
||||
if ($nas->isEnabled()) {
|
||||
|
||||
// Point filename at the new file (uploadDirectToNas uses this for ext)
|
||||
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
|
||||
$update = ['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize, 'type' => $desiredType];
|
||||
if ($needsRelocate) $update['path'] = null; // force resolveVideoDir to pick a fresh type-appropriate dir
|
||||
$video->update($update);
|
||||
|
||||
try {
|
||||
// Pass null for thumb — we don't want to overwrite the existing thumbnail
|
||||
$nas->uploadDirectToNas($video, $tempAbsPath, null);
|
||||
$video->refresh();
|
||||
$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) {
|
||||
\Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
|
||||
// Temp file still exists — fall through to organizeLocalFiles below
|
||||
|
||||
@ -1198,12 +1198,15 @@
|
||||
}
|
||||
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
||||
.yt-main.video-view-page { padding: 0 !important; }
|
||||
.yt-filter-bar {
|
||||
.yt-filter-bar-wrap {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
margin: -16px -16px 16px !important;
|
||||
}
|
||||
.yt-filter-bar {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -108,6 +108,7 @@
|
||||
<div class="um-track-card" id="edit-tc-t1-card">
|
||||
<div class="um-tc-body">
|
||||
<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>
|
||||
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
|
||||
<div class="um-tc-info">
|
||||
@ -116,12 +117,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<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')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
@ -203,6 +198,24 @@
|
||||
<style>
|
||||
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
|
||||
#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>
|
||||
|
||||
<script>
|
||||
@ -288,8 +301,9 @@ function openEditVideoModal(videoId) {
|
||||
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
|
||||
_editRenderSlides('t1');
|
||||
|
||||
// Extra tracks
|
||||
if (v.is_audio && v.audio_tracks) {
|
||||
// Extra tracks — show whenever the row is declared music, even if the
|
||||
// primary file is still a video (mid-conversion state).
|
||||
if ((v.type === 'music' || v.is_audio) && v.audio_tracks) {
|
||||
v.audio_tracks.forEach(track => _editAddExistingTrack(track));
|
||||
}
|
||||
|
||||
@ -446,33 +460,16 @@ function _editFmtSize(bytes) {
|
||||
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
|
||||
// ── Track ordering (drag-and-drop) ────────────────────────────────────────────
|
||||
function _editUpdateTrackPositions() {
|
||||
const list = document.getElementById('edit-tc-list');
|
||||
if (!list) return;
|
||||
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
|
||||
const total = cards.length;
|
||||
cards.forEach((card, i) => {
|
||||
const numEl = card.querySelector('.um-tc-num');
|
||||
if (numEl) numEl.textContent = i + 1;
|
||||
const badge = card.querySelector('.um-tc-primary');
|
||||
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
|
||||
const first = cards[0];
|
||||
@ -485,6 +482,93 @@ 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) {
|
||||
if (!wrap || !code) return;
|
||||
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
|
||||
@ -863,6 +947,7 @@ function _editTrackCard(n, trackId, isExisting) {
|
||||
card.innerHTML = `
|
||||
<div class="um-tc-body">
|
||||
<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>
|
||||
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
|
||||
<div class="um-tc-info">
|
||||
@ -871,8 +956,6 @@ function _editTrackCard(n, trackId, isExisting) {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user