Changeset 183875b in Klonkt for src


Ignore:
Timestamp:
06/20/2026 05:23:08 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
d727e92
Parents:
459acd9
Message:

feat(audio): per-track "open in" Spotify/YouTube/SoundCloud links

New per-track fields link_spotify/link_youtube/link_soundcloud (audio_tracks),
editable in the track editor (https + correct host validated). Shown as small
brand icons per track row in standalone track embeds, albums and playlists —
click opens the track on that service (new tab). buster audio.css?v=8.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
8 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/audio.css

    r459acd9 r183875b  
    8282  text-overflow: ellipsis;
    8383}
     84
     85/* "Open in" Spotify/YouTube/SoundCloud — kleine brand-iconen per track. */
     86.pat-links {
     87  display: inline-flex; align-items: center; gap: 0.1rem;
     88  flex: 0 0 auto;
     89}
     90/* Single + album-rij (.post-audio-track is flex) → duw de links naar rechts. */
     91.post-audio-track .pat-links { margin-left: auto; padding-right: 0.5rem; }
     92/* Playlist-rij: de li flex maken zodat de links naast de afspeel-rij passen. */
     93.post-album-track-compact { display: flex; align-items: center; }
     94.post-album-track-compact .pat-row { flex: 1 1 auto; min-width: 0; }
     95.post-album-track-compact .pat-links { padding-right: 0.6rem; }
     96.pat-link {
     97  display: inline-flex; align-items: center; justify-content: center;
     98  width: 26px; height: 26px;
     99  opacity: 0.75;
     100  transition: opacity 120ms, transform 120ms;
     101}
     102.pat-link svg { width: 17px; height: 17px; }
     103.pat-link:hover { opacity: 1; transform: scale(1.12); }
     104.pat-link--spotify    { color: #1DB954; }
     105.pat-link--youtube    { color: #FF0000; }
     106.pat-link--soundcloud { color: #FF5500; }
    84107
    85108/* ============================================================
  • src/config/database.js

    r459acd9 r183875b  
    9292  ensureColumn('audio_tracks', 'credit', 'TEXT');   // eigenaar/credit (copyright-houder)
    9393  ensureColumn('audio_tracks', 'license', 'TEXT');  // licentie (bv. "CC BY 4.0", "Alle rechten voorbehouden")
     94  ensureColumn('audio_tracks', 'link_spotify',    'TEXT');  // "open in"-links per track
     95  ensureColumn('audio_tracks', 'link_youtube',    'TEXT');
     96  ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
    9497
    9598  // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
  • src/routes/admin-audio.js

    r459acd9 r183875b  
    7272
    7373const router = express.Router();
     74
     75// "Open in"-platformlinks per track: alleen https + de juiste host accepteren
     76// (href komt ongeescaped in de view → scheme/host-guard tegen misbruik).
     77const LINK_DOMAINS = {
     78  spotify: ['spotify.com'],
     79  youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
     80  soundcloud: ['soundcloud.com'],
     81};
     82function platformLink(url, domains) {
     83  const u = String(url || '').trim();
     84  if (!u || !/^https:\/\//i.test(u)) return null;
     85  try {
     86    const h = new URL(u).hostname.toLowerCase();
     87    if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
     88  } catch (e) { /* ongeldige URL */ }
     89  return null;
     90}
    7491
    7592router.get('/', requireGod, (req, res) => {
     
    172189    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
    173190    const finalLicense = (req.body.license || '').trim() || null;
     191    const finalLinkSpotify    = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
     192    const finalLinkYoutube    = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
     193    const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
    174194
    175195    console.log('[admin-audio] upload received:', {
     
    222242      console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
    223243      db.prepare(`
    224         INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, media_id, position)
    225         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
     244        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
     245        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
    226246          (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
    227247          0
     
    233253        coverUrl,
    234254        finalCredit, finalLicense,
     255        finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
    235256        mediaId, site.id
    236257      );
     
    378399  const t = db.prepare(`
    379400    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
    380            t.credit, t.license, t.position, t.created_at, m.filename
     401           t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
     402           t.position, t.created_at, m.filename
    381403    FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
    382404    WHERE t.id = ? AND t.site_id = ?
     
    445467  if (Object.prototype.hasOwnProperty.call(body, 'license')) {
    446468    fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
     469  }
     470  if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
     471    fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
     472  }
     473  if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
     474    fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
     475  }
     476  if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
     477    fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
    447478  }
    448479
  • src/routes/posts.js

    r459acd9 r183875b  
    529529      const placeholders = trackIds.map(() => '?').join(',');
    530530      const rows = db.prepare(`
    531         SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license, m.filename
     531        SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
     532               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
    532533        FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
    533534        WHERE t.site_id = ? AND t.id IN (${placeholders})
     
    544545          credit: r.credit || '',
    545546          license: r.license || '',
     547          link_spotify: r.link_spotify || '',
     548          link_youtube: r.link_youtube || '',
     549          link_soundcloud: r.link_soundcloud || '',
    546550          url: audioUrl(r.filename),
    547551        };
     
    554558      const placeholders = albumNames.map(() => '?').join(',');
    555559      const albumRows = db.prepare(`
    556         SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position, m.filename
     560        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
     561               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
    557562        FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
    558563        WHERE t.site_id = ? AND t.album IN (${placeholders})
     
    569574          artist: r.artist || '',
    570575          cover: r.cover_url || '',
     576          link_spotify: r.link_spotify || '',
     577          link_youtube: r.link_youtube || '',
     578          link_soundcloud: r.link_soundcloud || '',
    571579        });
    572580      }
  • src/services/AudioEmbedService.js

    r459acd9 r183875b  
    1111 */
    1212
     13// "Open in"-iconen (brand-gekleurd via CSS .pat-link--*).
     14const OPEN_IN_SVG = {
     15  spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
     16  youtube: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.1a3 3 0 00-2.1-2.12C19.04 4.5 12 4.5 12 4.5s-7.04 0-8.9.48A3 3 0 001 7.1 31.2 31.2 0 00.5 12 31.2 31.2 0 001 16.9a3 3 0 002.1 2.12c1.86.48 8.9.48 8.9.48s7.04 0 8.9-.48A3 3 0 0023 16.9 31.2 31.2 0 0023.5 12 31.2 31.2 0 0023 7.1zM9.75 15.5v-7l6 3.5-6 3.5z"/></svg>',
     17  soundcloud: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 14v4M7.5 11v7M11 9v9"/><path d="M14.5 9.5V18h4a3 3 0 100-6 4 4 0 00-4-2.5z"/></svg>',
     18};
     19
    1320class AudioEmbedService {
     21  // Kleine "open in"-links voor een track (Spotify/YouTube/SoundCloud). De hrefs
     22  // zijn server-side al gevalideerd (alleen https + juiste host). Geeft '' als er
     23  // geen links zijn. Wordt naast de play-knop gezet (buiten de knop → geen
     24  // conflict met afspelen).
     25  static openInLinks(t) {
     26    if (!t) return '';
     27    const out = [];
     28    const add = (url, key, label) => {
     29      if (!url) return;
     30      out.push(`<a class="pat-link pat-link--${key}" href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" title="Open in ${label}" aria-label="Open in ${label}">${OPEN_IN_SVG[key]}</a>`);
     31    };
     32    add(t.link_spotify, 'spotify', 'Spotify');
     33    add(t.link_youtube, 'youtube', 'YouTube');
     34    add(t.link_soundcloud, 'soundcloud', 'SoundCloud');
     35    return out.length ? `<span class="pat-links">${out.join('')}</span>` : '';
     36  }
     37
    1438  static detectProvider(url) {
    1539    if (!url || typeof url !== 'string') return null;
     
    273297    ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
    274298  </div>
     299  ${this.openInLinks(t)}
    275300</div>`;
    276301    });
     
    312337        ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
    313338      </div>
     339      ${this.openInLinks(t)}
    314340    </li>`;
    315341      }).join('\n');
     
    433459        ${durHtml}
    434460      </button>
     461      ${this.openInLinks(t)}
    435462    </li>`;
    436463      }).join('\n');
  • src/services/PlaylistService.js

    r459acd9 r183875b  
    9494    // filenames (only tracks with a media file are playable).
    9595    const tracks = db.prepare(`
    96       SELECT t.id, t.title, t.artist, t.duration, t.cover_url, m.filename
     96      SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
     97             t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
    9798      FROM playlist_tracks pt
    9899      JOIN audio_tracks t   ON t.id = pt.track_id
     
    110111        cover: t.cover_url || p.cover_url || '',
    111112        duration: t.duration || 0,
     113        link_spotify: t.link_spotify || '',
     114        link_youtube: t.link_youtube || '',
     115        link_soundcloud: t.link_soundcloud || '',
    112116        url: urlFor ? urlFor(t.filename) : null,
    113117      }));
  • src/views/partials/track-editor.ejs

    r459acd9 r183875b  
    430430
    431431            <div class="te-field">
     432              <span>Open in <small>(links naar dezelfde track elders)</small></span>
     433              <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
     434                     placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
     435              <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
     436                     placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
     437              <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
     438                     placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
     439            </div>
     440
     441            <div class="te-field">
    432442              <span>Cover</span>
    433443              <div class="te-cover-row">
     
    664674          credit:  $('#te-credit').value.trim() || null,
    665675          license: $('#te-license').value.trim() || null,
     676          link_spotify:    $('#te-link-spotify').value.trim() || null,
     677          link_youtube:    $('#te-link-youtube').value.trim() || null,
     678          link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
    666679          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
    667680          cover_url: urlInput.value.trim() || null,
  • src/views/shell.ejs

    r459acd9 r183875b  
    168168     anywhere (admin previews, post embeds, etc). The player itself is
    169169     a singleton — see the script tag near </body>. -->
    170 <link rel="stylesheet" href="/assets/css/audio.css?v=7">
     170<link rel="stylesheet" href="/assets/css/audio.css?v=8">
    171171<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
    172172<link rel="stylesheet" href="/assets/css/embed.css?v=8">
Note: See TracChangeset for help on using the changeset viewer.