diff --git a/README.md b/README.md index f9bb21dd..f69f0ccc 100644 --- a/README.md +++ b/README.md @@ -53,22 +53,23 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** fro | Category | Name | What they do | Link | |---|---|---|---| -| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | -| Artists & Creators | Killah Trakz | Industry Secret Villain and Media Influencer | [@killahtrakz](https://www.instagram.com/killahtrakz/) | -| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | Adrianna Claro | Soul and Contemporary R&B Singer, Songwriter and Composer | [Spotify](https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG) · [Apple Music](https://music.apple.com/us/album/happy/1592026727?i=1592026728) | | Artists & Creators | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | | Artists & Creators | Dead röses | Cork-based punk rock band | [@dead_rosesband](https://www.instagram.com/dead_rosesband) | -| Artists & Creators | NIHIL | Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness | [@somosnihil](https://www.instagram.com/somosnihil/) | +| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | +| Artists & Creators | Killah Trakz | Industry Secret Villain and Media Influencer | [@killahtrakz](https://www.instagram.com/killahtrakz/) · [Spotify](https://open.spotify.com/artist/6nePYOqT4E7D4C48twPtKk) · [Apple Music](https://music.apple.com/us/artist/killah-trakz/371296633) | +| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | NIHIL | Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness | [@somosnihil](https://www.instagram.com/somosnihil/) · [Spotify](https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV) | | Instrument Builders & Repair | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) | -| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | | Instrument Builders & Repair | Kris Luthier | Instrument repair and restoration | [@krisluthier](https://www.instagram.com/krisluthier) | -| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | -| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | +| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | +| Media & Community | Not Another Audio Podcast | Audio, music and tech industry podcast by working professionals | [Libsyn](https://directory.libsyn.com/shows/view/id/20f5a1e3-6fea-4d41-8cdf-451ce6fc6cda) | +| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | +| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | | Music & Karaoke Technology | Beltr | Local, subscription-free karaoke software | [beltr.app](https://beltr.app/) | | Music & Karaoke Technology | Seratone | TV-based karaoke system | [seratone.audio](https://seratone.audio/) | -| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | -| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | -| Media & Community | Not Another Audio Podcast | Audio, music and tech industry podcast by working professionals | [Libsyn](https://directory.libsyn.com/shows/view/id/20f5a1e3-6fea-4d41-8cdf-451ce6fc6cda) | +| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | +| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | | Writers & Storytellers | Alexandre Borges | Portuguese writer, screenwriter, and cultural commentator | [Books & author profile](https://www.instagram.com/alexgram_b/) | diff --git a/static/css/daw.css b/static/css/daw.css index d015e4a0..5e139735 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -4564,6 +4564,19 @@ input, textarea { font-family: inherit; } } .lib-friend:hover .lib-friend-ig, .lib-friend:hover .lib-friend-link { opacity: 1; } +/* A card with several links: the card itself is not a link, each pill is. */ +.lib-friend-multi:hover { background: var(--panel); border-color: var(--border); color: var(--fg-2); } +.lib-friend-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; margin-top: auto; padding-top: 3px; } +.lib-friend-pill { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 7px; border-radius: 999px; + border: 1px solid var(--border-strong); background: var(--panel-2); + color: var(--fg-2); font-size: 9.5px; font-weight: 600; text-decoration: none; + transition: color var(--t-fast), border-color var(--t-fast); +} +.lib-friend-pill:hover { color: var(--fg); border-color: var(--accent); } +.lib-friend-pill .lib-friend-link, +.lib-friend-pill .lib-friend-ig { width: 10px; height: 10px; margin-top: 0; padding-top: 0; opacity: 1; } .lib-friend-name { width: 100%; font-size: 11px; diff --git a/static/img/friends/adrianna-claro.webp b/static/img/friends/adrianna-claro.webp new file mode 100644 index 00000000..38f1e7f6 Binary files /dev/null and b/static/img/friends/adrianna-claro.webp differ diff --git a/static/js/catalog.js b/static/js/catalog.js index 06231b0e..e153d7a1 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -79,17 +79,34 @@ const FRIEND_GROUPS = [ { name: "Killah Trakz", roleKey: "friends.role.killahTrakz", - url: "https://www.instagram.com/killahtrakz/", + links: [ + { label: "Instagram", url: "https://www.instagram.com/killahtrakz/" }, + { label: "Spotify", url: "https://open.spotify.com/artist/6nePYOqT4E7D4C48twPtKk" }, + { label: "Apple Music", url: "https://music.apple.com/us/artist/killah-trakz/371296633" }, + ], logo: "/img/friends/killah-trakz.jpg", avatar: true, }, { name: "NIHIL", roleKey: "friends.role.nihil", - url: "https://www.instagram.com/somosnihil/", + links: [ + { label: "Instagram", url: "https://www.instagram.com/somosnihil/" }, + { label: "Spotify", url: "https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV" }, + ], logo: "/img/friends/nihil.jpg", avatar: true, }, + { + name: "Adrianna Claro", + roleKey: "friends.role.adriannaClaro", + logo: "/img/friends/adrianna-claro.webp", + avatar: true, + links: [ + { label: "Spotify", url: "https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG" }, + { label: "Apple Music", url: "https://music.apple.com/us/album/happy/1592026727?i=1592026728" }, + ], + }, ], }, { @@ -3523,13 +3540,23 @@ function friendGlyph(url) { return svg; } -// One partner card: image (or initial badge), name, role, link glyph. -function friendCard(f) { +/** A link that opens outside the app, as every partner link does. */ +function friendLink(url) { const a = document.createElement("a"); - a.className = "lib-friend"; - a.href = f.url; + a.href = url; a.target = "_blank"; a.rel = "noopener noreferrer"; + return a; +} + +// One partner card: image (or initial badge), name, role, link glyph. A card +// with one `url` is a link as a whole. A card with several `links` cannot be: +// a link inside a link is invalid HTML, and which one a click follows is up to +// the browser. So it is a plain card with one labelled link per destination. +function friendCard(f) { + const multi = Array.isArray(f.links) && f.links.length > 0; + const a = multi ? document.createElement("div") : friendLink(f.url); + a.className = multi ? "lib-friend lib-friend-multi" : "lib-friend"; a.title = f.name; // A monogram badge (first initial) keeps the card on-brand when an entry has @@ -3575,7 +3602,20 @@ function friendCard(f) { a.appendChild(role); } - a.appendChild(friendGlyph(f.url)); + if (!multi) { + a.appendChild(friendGlyph(f.url)); + return a; + } + const row = document.createElement("span"); + row.className = "lib-friend-links"; + for (const link of f.links) { + const l = friendLink(link.url); + l.className = "lib-friend-pill"; + l.title = `${f.name}: ${link.label}`; + l.append(friendGlyph(link.url), link.label); + row.appendChild(l); + } + a.appendChild(row); return a; } diff --git a/static/js/i18n.js b/static/js/i18n.js index d6e9f29b..4bf2534f 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -733,6 +733,7 @@ const en = { "friends.role.deadRoses": "Cork-based punk rock band", "friends.role.killahTrakz": "Industry Secret Villain and Media Influencer", "friends.role.nihil": "Modern alternative metal from Argentina. Fueled by emotionally charged vocals, anger and emptiness", + "friends.role.adriannaClaro": "Soul and Contemporary R&B Singer, Songwriter and Composer", "friends.role.empressEffects": "Boutique effects pedals", "friends.role.thomann": "Large music-equipment retailer", "friends.role.beltr": "Local, subscription-free karaoke software", @@ -1621,6 +1622,7 @@ const pl = { "friends.role.deadRoses": "Zespół punkrockowy z Corku", "friends.role.killahTrakz": "Sekretny złoczyńca branży i influencer medialny", "friends.role.nihil": "Nowoczesny metal alternatywny z Argentyny. Napędzany emocjonalnym wokalem, gniewem i pustką", + "friends.role.adriannaClaro": "Wokalistka soul i współczesnego R&B, autorka piosenek i kompozytorka", "friends.role.empressEffects": "Butikowe efekty gitarowe", "friends.role.thomann": "Duży sklep ze sprzętem muzycznym", "friends.role.beltr": "Lokalne karaoke bez abonamentu", @@ -2500,6 +2502,7 @@ const ja = { "friends.role.deadRoses": "コーク拠点のパンクロックバンド", "friends.role.killahTrakz": "業界の隠れた悪役でメディアインフルエンサー", "friends.role.nihil": "アルゼンチン発のモダンなオルタナティヴメタル。感情的なボーカル、怒り、そして虚無が原動力", + "friends.role.adriannaClaro": "ソウルとコンテンポラリーR&Bのシンガー、ソングライター、作曲家", "friends.role.empressEffects": "ブティック系エフェクターペダル", "friends.role.thomann": "大手の楽器機材販売店", "friends.role.beltr": "サブスク不要、ローカルで動くカラオケソフト", @@ -3355,6 +3358,7 @@ const zhHans = { "friends.role.deadRoses": "来自科克的朋克摇滚乐队", "friends.role.killahTrakz": "行业隐秘反派与媒体影响者", "friends.role.nihil": "来自阿根廷的现代另类金属。以饱含情感的人声、愤怒与虚无为驱动", + "friends.role.adriannaClaro": "灵魂乐与当代 R&B 歌手、词曲作者和作曲家", "friends.role.empressEffects": "精品效果器", "friends.role.thomann": "大型乐器器材零售商", "friends.role.beltr": "本地运行、无需订阅的卡拉OK软件", @@ -4211,6 +4215,7 @@ const de = { "friends.role.deadRoses": "Punkrock-Band aus Cork", "friends.role.killahTrakz": "Heimlicher Bösewicht der Branche und Medien-Influencer", "friends.role.nihil": "Moderner Alternative Metal aus Argentinien. Angetrieben von emotionsgeladenem Gesang, Wut und Leere", + "friends.role.adriannaClaro": "Soul- und Contemporary-R&B-Sängerin, Songwriterin und Komponistin", "friends.role.empressEffects": "Boutique-Effektpedale", "friends.role.thomann": "Großer Händler für Musik-Equipment", "friends.role.beltr": "Lokale Karaoke-Software ohne Abo", @@ -5077,6 +5082,7 @@ const pt = { "friends.role.deadRoses": "Banda de punk rock de Cork", "friends.role.killahTrakz": "Vilão secreto da indústria e influenciador digital", "friends.role.nihil": "Metal alternativo moderno da Argentina. Movido por vocais carregados de emoção, raiva e vazio", + "friends.role.adriannaClaro": "Cantora, compositora e autora de soul e R&B contemporâneo", "friends.role.empressEffects": "Pedais de efeito boutique", "friends.role.thomann": "Grande varejista de equipamentos musicais", "friends.role.beltr": "Software de karaokê local, sem assinatura", @@ -5944,6 +5950,7 @@ const id = { "friends.role.deadRoses": "Band punk rock asal Cork", "friends.role.killahTrakz": "Penjahat Rahasia Industri dan Influencer Media", "friends.role.nihil": "Metal alternatif modern dari Argentina. Digerakkan oleh vokal penuh emosi, amarah, dan kehampaan", + "friends.role.adriannaClaro": "Penyanyi, penulis lagu, dan komposer soul dan R&B kontemporer", "friends.role.empressEffects": "Pedal efek butik", "friends.role.thomann": "Peritel besar perangkat musik", "friends.role.beltr": "Perangkat lunak karaoke lokal tanpa langganan", @@ -6800,6 +6807,7 @@ const fr = { "friends.role.deadRoses": "Groupe de punk rock basé à Cork", "friends.role.killahTrakz": "Méchant secret de l'industrie et influenceur média", "friends.role.nihil": "Metal alternatif moderne d'Argentine. Porté par un chant chargé d'émotion, la colère et le vide", + "friends.role.adriannaClaro": "Chanteuse, autrice et compositrice de soul et de R&B contemporain", "friends.role.empressEffects": "Pédales d'effets boutique", "friends.role.thomann": "Grand détaillant de matériel de musique", "friends.role.beltr": "Logiciel de karaoké local, sans abonnement", @@ -7840,6 +7848,7 @@ const es = { "friends.role.deadRoses": "Banda de punk rock de Cork", "friends.role.killahTrakz": "Villano secreto de la industria e influencer de medios", "friends.role.nihil": "Metal alternativo moderno de Argentina. Impulsado por voces cargadas de emoción, rabia y vacío", + "friends.role.adriannaClaro": "Cantante, compositora y autora de soul y R&B contemporáneo", "friends.role.empressEffects": "Pedales de efectos boutique", "friends.role.thomann": "Gran tienda de equipo musical", "friends.role.beltr": "Software de karaoke local y sin suscripción", @@ -8728,6 +8737,7 @@ const ko = { "friends.role.deadRoses": "코크를 기반으로 활동하는 펑크 록 밴드", "friends.role.killahTrakz": "업계의 숨은 악당이자 미디어 인플루언서", "friends.role.nihil": "아르헨티나에서 온 모던 얼터너티브 메탈. 감정적인 보컬과 분노, 공허가 원동력", + "friends.role.adriannaClaro": "소울과 컨템퍼러리 R&B 가수, 송라이터, 작곡가", "friends.role.empressEffects": "부티크 이펙터 페달", "friends.role.thomann": "대형 음악 장비 판매점", "friends.role.beltr": "구독이 필요 없는 로컬 노래방 소프트웨어", diff --git a/tests/e2e/we-recommend.spec.mjs b/tests/e2e/we-recommend.spec.mjs new file mode 100644 index 00000000..9a157960 --- /dev/null +++ b/tests/e2e/we-recommend.spec.mjs @@ -0,0 +1,53 @@ +// We Recommend: a partner card with one link is a link as a whole; a card with +// several (Adrianna Claro: Spotify and Apple Music) is a plain card with one +// labelled link per destination, since a link cannot hold other links. + +import { test, expect } from "@playwright/test"; + +import { seedLibrary, stubUpdateCheck } from "./helpers.mjs"; + +async function openWeRecommend(page) { + await seedLibrary(page); + await stubUpdateCheck(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#friendsBtn").click(); +} + +test.describe("We Recommend", () => { + test("a card with two links offers both, each opening outside the app", async ({ page }) => { + await openWeRecommend(page); + const card = page.locator(".lib-friend").filter({ hasText: "Adrianna Claro" }); + await expect(card).toHaveCount(1); + await expect(card).toBeVisible(); + // Not a link itself, so the two below are the only things to click. + expect(await card.evaluate((el) => el.tagName)).toBe("DIV"); + // Her photo loads, rather than falling back to the monogram. + await expect(card.locator("img.lib-friend-avatar")).toHaveAttribute("src", "/img/friends/adrianna-claro.webp"); + await expect.poll(() => card.locator("img.lib-friend-avatar").evaluate((img) => img.naturalWidth)).toBeGreaterThan(0); + + const spotify = card.locator(".lib-friend-pill", { hasText: "Spotify" }); + const apple = card.locator(".lib-friend-pill", { hasText: "Apple Music" }); + await expect(spotify).toHaveAttribute("href", "https://open.spotify.com/album/10fZ48iJhIe3lWcXSMUlFG"); + await expect(apple).toHaveAttribute("href", "https://music.apple.com/us/album/happy/1592026727?i=1592026728"); + for (const link of [spotify, apple]) { + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noopener noreferrer"); + } + }); + + test("a card with one link is still a link as a whole", async ({ page }) => { + await openWeRecommend(page); + const card = page.locator(".lib-friend").filter({ hasText: "Analog4Lyfe" }); + expect(await card.evaluate((el) => el.tagName)).toBe("A"); + await expect(card).toHaveAttribute("href", "https://www.instagram.com/analog4lyfe"); + await expect(card.locator(".lib-friend-pill")).toHaveCount(0); + }); + + test("Killah Trakz and NIHIL offer Instagram and Spotify, and Killah Trakz Apple Music too", async ({ page }) => { + await openWeRecommend(page); + const pills = (name) => page.locator(".lib-friend").filter({ hasText: name }).locator(".lib-friend-pill"); + await expect(pills("Killah Trakz")).toHaveText(["Instagram", "Spotify", "Apple Music"]); + await expect(pills("NIHIL")).toHaveText(["Instagram", "Spotify"]); + await expect(pills("NIHIL").filter({ hasText: "Spotify" })).toHaveAttribute("href", "https://open.spotify.com/artist/1OeKplJxFNM6JHrWeo2SaV"); + }); +});