commit - 1f11edf54013c82402ffbc058de59e9668c52d10
commit + 20f66d3e663eca1889354da880458264430f4296
blob - /dev/null
blob + bf73340068cfa7919881cbe6ec81ab3a04d9d18a (mode 644)
Binary files /dev/null and assets/left.webp differ
blob - /dev/null
blob + 5ca9fd161b584d7e202af278ce91fe1a28ea0744 (mode 644)
Binary files /dev/null and assets/right.webp differ
blob - d9fb2294a30528aac890909fc858c5f7f6cecf3c
blob + 21f0426df1c2b249c91f69dcf4278280ed828cf2
--- flake.lock
+++ flake.lock
},
"nixpkgs_2": {
"locked": {
- "lastModified": 1783915482,
- "narHash": "sha256-FmieJB8/OUvNxbkboi7+IGfIuSXY3nF/hZQm8kD0r50=",
+ "lastModified": 1785602060,
+ "narHash": "sha256-z7D96eESRM4CPV/XtwpwFn8IDdfLAmxz6lVWrGYXvR4=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "6cdc7fc76e8bf7fde9fa43a849fcaaa70e230dee",
+ "rev": "a5cbcfe954791221bfffe2307f7d1a1bf61a871e",
"type": "github"
},
"original": {
blob - fa4250e86cb91b61f1403c67598d0f928f29971f
blob + 7eb18dc8f5f34813c0ceca1d765a862f5df6c185
--- src/Main.purs
+++ src/Main.purs
serveAsset "image/png" "assets/favicon.png" res
"/cover.webp" ->
serveAsset "image/webp" "assets/cover.webp" res
+ "/gutter-left.webp" ->
+ serveAsset "image/webp" "assets/left.webp" res
+ "/gutter-right.webp" ->
+ serveAsset "image/webp" "assets/right.webp" res
"/" ->
serveIndex regEnabled allUsers "" res
"/register" ->
blob - 161c64f63caa4c654cf4e243905cfea12725b77e
blob + d7b603af4cde642bc457bef5c84c662913e9fa2f
--- src/Registrations.purs
+++ src/Registrations.purs
, "client.js"
, "favicon.png"
, "cover.webp"
+ , "gutter-left.webp"
+ , "gutter-right.webp"
, "cover"
, "metrics"
, "proxy"
blob - 1f99cfd0d81d875b4ed6319d26fca553ed0891cd
blob + e019ebda3e58402c9a341929d6b9b9af09bb263f
--- src/State.elm
+++ src/State.elm
, allUsers = flags.allUsers
, searchInput = ""
, activeSearch = Nothing
- , tabsVisible = True
, page = page
, registrationEnabled = flags.registrationEnabled
, regSlug = ""
UrlRequested urlRequest ->
case urlRequest of
Browser.Internal url ->
- ( model, Nav.pushUrl model.navKey (Url.toString url) )
+ if String.isEmpty url.path then
+ ( model, Cmd.none )
+ else
+ ( model, Nav.pushUrl model.navKey (Url.toString url) )
+
Browser.External href ->
( model, Nav.load href )
else
targetOffset
+
+ shouldLoad =
+ model.page /= MainPage || userChanged || model.offset /= finalOffset || model.activeFilter /= finalFilter || model.activeSearch /= finalSearch
in
- if model.page /= MainPage || userChanged || model.offset /= finalOffset || model.activeFilter /= finalFilter || model.activeSearch /= finalSearch then
+ if shouldLoad then
let
newModel =
{ model
)
else
- ( { model | activeTab = ListensTab, page = MainPage }, Cmd.none )
+ ( { model | page = MainPage }, Cmd.none )
Tick time ->
if model.page == MainPage && not model.loading then
Cmd.none
)
- ToggleTabs ->
- ( { model | tabsVisible = not model.tabsVisible }, Cmd.none )
-
SetStatsPeriod period ->
( { model
| statsPeriod = period
UnhoverCover ->
( { model | hoveredCover = Nothing }, Cmd.none )
+ CoverBackgroundClick ->
+ ( { model | hoveredCover = Nothing }, Cmd.none )
+
ExpandSection section ->
( { model | expandedSections = Set.insert section model.expandedSections }, Cmd.none )
blob - a77bc9f485b85c13813cc966cb16a396212d47b6
blob + 39c343b9a4af4e29e2d61256a1efd6f142255451
--- src/Templates.purs
+++ src/Templates.purs
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Intel+One+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
+ :root {
+ color-scheme: dark;
+ --bg: #212529;
+ --bg-alt: #2b3035;
+ --bg-dark: #343a40;
+ --bg-inactive: #343a40;
+ --fg: #ced4da;
+ --fg-alt: #6c757d;
+ --fg-alt-dark: #adb5bd;
+ --fg-light: #495057;
+ --fg-intense: #f8f9fa;
+ --highlight: #2b375c;
+ --cyan: #9bf6ff;
+ --red: #ffadad;
+ --red-light: #4a2f33;
+ --yellow: #fdffb6;
+ --green: #caffbf;
+ --green-light: #2f4a35;
+ --blue: #9bb1ff;
+ --purple: #ffc6ff;
+ --border: var(--fg-light);
+ --space-xs: 0.25em;
+ --space-sm: 0.4em;
+ --space-md: 0.6em;
+ --space-lg: 1em;
+ --window-pad: 0.5em 1.15em;
+ --gutter: 25%;
+ --radius: 0;
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ html, body {
+ height: 100%;
+ }
+
body {
font-family: 'Intel One Mono', 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- background: oklch(0.194 0.0345 225.31);
- color: oklch(0.91 0.012 225.31);
+ font-size: 16px;
+ line-height: 1.5;
+ background-color: var(--bg);
+ color: var(--fg);
margin: 0;
- line-height: 1.6;
+ padding: 0 var(--gutter);
}
- ::selection {
- background: oklch(0.42 0.04 225.31);
- color: oklch(0.91 0.012 225.31);
- }
-
+ /* The app window: inset from the gutters so content never sits on an edge. */
.container {
- max-width: 800px;
- margin: 0 auto;
- padding: 24px 20px;
+ padding: var(--window-pad);
}
+ /* Artwork filling the left and right gutters, pinned to the viewport. */
+ body::before,
+ body::after {
+ content: "";
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ width: var(--gutter);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ pointer-events: none;
+ z-index: -1;
+ }
+
+ body::before {
+ left: 0;
+ background-image: url("/gutter-left.webp");
+ }
+
+ body::after {
+ right: 0;
+ background-image: url("/gutter-right.webp");
+ }
+
+ /* Narrow viewports can't spare 30% a side; fall back to a flat gutter. */
+ @media (max-width: 60em) {
+ :root {
+ --gutter: var(--space-lg);
+ }
+
+ body::before,
+ body::after {
+ display: none;
+ }
+ }
+
+ a {
+ color: var(--blue);
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ a:visited {
+ color: var(--purple);
+ }
+
h1 {
- color: oklch(0.91 0.012 225.31);
- margin: 0 0 20px 0;
- font-size: 24px;
- border-bottom: 2px solid oklch(0.42 0.04 225.31);
- padding-bottom: 8px;
+ font-size: 1.25em;
+ margin: 0 0 var(--space-xs) 0;
+ color: var(--fg);
}
- h1.site-header {
- border-bottom: none;
- padding-bottom: 0;
- line-height: 0;
+ h2 {
+ font-size: 1em;
+ margin: var(--space-md) 0 var(--space-sm) 0;
+ color: var(--fg);
+ }
+
+ header {
+ margin-bottom: var(--space-md);
+ border-bottom: solid 0.1em var(--border);
+ padding-bottom: var(--space-sm);
+ }
+
+ .bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ align-items: center;
+ margin-bottom: var(--space-md);
+ }
+
+ input,
+ button,
+ .tab-btn,
+ .tab-btn:visited {
+ background-color: var(--bg-alt);
+ color: var(--fg);
+ border: solid 0.1em var(--border);
+ padding: var(--space-xs) var(--space-sm);
+ font: inherit;
+ border-radius: var(--radius);
+ line-height: 1.4;
+ }
+
+ input {
+ flex: 1;
+ min-width: 12em;
+ }
+
+ input::placeholder {
+ color: var(--fg-alt);
+ }
+
+ input:focus {
+ outline: none;
+ border-color: var(--cyan);
+ }
+
+ button,
+ .tab-btn {
cursor: pointer;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
}
- h1.site-header img {
- display: block;
+ button:hover,
+ .tab-btn:hover {
+ background-color: var(--highlight);
+ border-color: var(--cyan);
+ text-decoration: none;
+ }
+
+ button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ button.active,
+ button.on,
+ .tab-btn.active,
+ .tab-btn.on {
+ background-color: var(--cyan);
+ color: var(--bg);
+ border-color: var(--cyan);
+ }
+
+ button .n,
+ .tab-btn .n {
+ color: var(--fg-alt);
+ }
+
+ button.active .n,
+ button.on .n,
+ .tab-btn.active .n,
+ .tab-btn.on .n {
+ color: var(--bg);
+ }
+
+ table {
+ border-collapse: collapse;
width: 100%;
- height: 80px;
- object-fit: cover;
- border-radius: 4px;
- border: 1px solid oklch(0.42 0.04 225.31);
+ border: solid 0.1em var(--border);
}
- ul {
- list-style: none;
+ th,
+ td {
+ border: solid 0.1em var(--border);
+ padding: var(--space-sm) var(--space-md);
+ text-align: left;
+ vertical-align: top;
+ }
+
+ th {
+ background-color: var(--bg-dark);
+ color: var(--fg);
+ cursor: pointer;
+ user-select: none;
+ position: sticky;
+ top: 0;
+ white-space: nowrap;
+ }
+
+ th:hover {
+ background-color: var(--highlight);
+ }
+
+ tbody tr:nth-child(even) {
+ background-color: var(--bg-alt);
+ }
+
+ tbody tr:hover {
+ background-color: var(--highlight);
+ }
+
+ .d {
+ color: var(--fg-alt);
+ white-space: nowrap;
+ width: 1%;
+ }
+
+ .track-cell {
+ position: relative;
+ }
+
+ .track-line {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-sm);
+ }
+
+ .track-link,
+ .artist-link,
+ .album-link,
+ .label-link {
+ background: none;
+ border: none;
padding: 0;
- margin: 0 0 20px 0;
+ color: var(--fg);
+ font: inherit;
+ cursor: pointer;
+ text-align: left;
+ user-select: text;
}
- li {
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- border-radius: 4px;
- padding: 5px 15px 5px 15px;
- margin-bottom: 5px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- box-shadow: 4px 4px 0px oklch(0.42 0.04 225.31);
- }
-
- li.success {
- background: oklch(0.27 0.038 225.31);
- border-color: oklch(0.42 0.04 225.31);
- box-shadow: 4px 4px 0px oklch(0.42 0.04 225.31);
- }
-
- .track-info {
- flex: 1;
- }
-
- .track-name {
- font-weight: bold;
- font-size: 16px;
- color: oklch(0.91 0.012 225.31);
- }
-
- .track-artist {
- font-size: 14px;
- color: oklch(0.70 0.035 225.31);
- margin-top: 1px;
- }
-
- .track-time {
- font-size: 12px;
- color: oklch(0.74 0.075 225.31);
- margin-top: 2px;
- }
-
.track-link {
- background: none;
- border: none;
- padding: 0;
- color: oklch(0.91 0.012 225.31);
font-weight: bold;
- font-size: 16px;
- font-family: inherit;
- cursor: pointer;
- text-align: left;
- user-select: text;
}
- .artist-link {
- background: none;
- border: none;
- padding: 0;
- color: oklch(0.70 0.035 225.31);
- font-size: 14px;
- font-family: inherit;
- cursor: pointer;
- text-align: left;
- user-select: text;
+ .artist-link,
+ .album-link,
+ .label-link {
+ color: var(--fg-alt);
}
- .album-link {
- background: none;
- border: none;
- padding: 0;
- color: oklch(0.74 0.075 225.31);
- text-decoration: underline;
- font-family: inherit;
- font-size: inherit;
- cursor: pointer;
- }
-
- .album-link:hover {
- color: oklch(0.78 0.13 357.86);
- }
-
+ .album-link,
.label-link {
- background: none;
- border: none;
- padding: 0;
- color: oklch(0.74 0.075 225.31);
text-decoration: underline;
- font-family: inherit;
- font-size: inherit;
- cursor: pointer;
}
+ .track-link:hover,
+ .artist-link:hover,
+ .album-link:hover,
.label-link:hover {
- color: oklch(0.78 0.13 357.86);
+ color: var(--blue);
}
+ .track-artist {
+ margin-top: var(--space-xs);
+ }
+
+ .track-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-sm);
+ margin-top: var(--space-xs);
+ color: var(--fg-alt);
+ font-size: 0.9em;
+ position: relative;
+ padding-right: 8em;
+ }
+
+ .track-release {
+ color: var(--fg-alt);
+ }
+
+ .genre-tag {
+ display: inline-block;
+ font-size: 0.85em;
+ color: var(--green);
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ }
+
+ .cover-cell {
+ width: 76px;
+ padding: var(--space-sm);
+ vertical-align: middle;
+ }
+
+ .cover-cell .track-cover {
+ width: 60px;
+ height: 60px;
+ object-fit: cover;
+ border: solid 0.1em var(--border);
+ background: var(--bg-alt);
+ cursor: pointer;
+ display: block;
+ }
+
+ .cover-wrapper {
+ display: none;
+ }
+
+ .cover-overlay {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.75);
+ z-index: 90;
+ }
+
.track-cover {
width: 60px;
height: 60px;
- border-radius: 4px;
object-fit: cover;
- background: rgba(255, 255, 255, 0.05);
- transition: transform 0.2s ease-in-out;
+ border: solid 0.1em var(--border);
+ background: var(--bg-alt);
cursor: pointer;
}
.track-cover.zoomed {
- transform: scale(5.0);
- z-index: 10;
- position: relative;
- box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 320px;
+ height: 320px;
+ z-index: 100;
+ border-color: var(--cyan);
}
- .loading {
- padding: 20px;
- color: oklch(0.74 0.075 225.31);
- text-align: center;
+ .similar-btn {
+ font-size: 0.75em;
+ padding: var(--space-xs) var(--space-sm);
}
+ .similar-row td {
+ padding: 0;
+ }
+
+ .similar-panel {
+ padding: var(--space-sm) var(--space-md);
+ background-color: var(--bg-alt);
+ border-top: solid 0.1em var(--border);
+ }
+
+ .similar-track {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-xs) 0;
+ }
+
+ .similar-track-info {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .similar-track-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .similar-track-artist {
+ color: var(--fg-alt);
+ font-size: 0.9em;
+ }
+
+ .similar-score {
+ color: var(--fg-alt);
+ font-size: 0.85em;
+ }
+
+ .similar-link {
+ font-size: 0.85em;
+ color: var(--blue);
+ }
+
+ .similar-link:hover {
+ color: var(--cyan);
+ }
+
+ .similar-loading,
+ .similar-error,
+ .similar-empty {
+ color: var(--fg-alt);
+ font-size: 0.9em;
+ }
+
+ .loading,
.error {
- padding: 20px;
- color: oklch(0.78 0.085 225.31);
- text-align: center;
+ padding: var(--space-md) 0;
+ color: var(--fg-alt);
}
- .small {
- font-size: 12px;
- color: oklch(0.74 0.075 225.31);
- margin-top: 20px;
- }
-
- .small a {
- color: oklch(0.70 0.035 225.31);
- text-decoration: none;
- }
-
- .small a:hover {
- color: oklch(0.78 0.13 357.86);
- text-decoration: underline;
- }
-
.pagination {
display: flex;
- justify-content: center;
- gap: 20px;
- margin-top: 20px;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ align-items: center;
+ margin: var(--space-md) 0;
}
- .page-btn {
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.91 0.012 225.31);
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- font-family: inherit;
- box-shadow: 2px 2px 0px oklch(0.42 0.04 225.31);
- }
-
- .page-btn:hover {
- background: oklch(0.32 0.045 225.31);
- border-color: oklch(0.52 0.05 225.31);
- }
-
- .page-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
.page-indicator {
+ color: var(--fg-alt);
+ font-size: 0.9em;
+ padding: 0 var(--space-sm);
+ }
+
+ .filter-banner {
display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
align-items: center;
- font-size: 14px;
- color: oklch(0.74 0.075 225.31);
+ margin-bottom: var(--space-md);
+ padding: var(--space-sm) var(--space-md);
+ background-color: var(--bg-alt);
+ border: solid 0.1em var(--border);
+ color: var(--fg-alt);
}
- .tabs {
- display: flex;
- gap: 10px;
- margin-top: 5px;
- margin-bottom: 20px;
+ .filter-label strong {
+ color: var(--fg);
}
- .tab-btn {
- background: none;
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.74 0.075 225.31);
- padding: 6px 14px;
- border-radius: 4px;
- cursor: pointer;
- font-family: inherit;
- font-size: 12px;
- text-decoration: none;
- display: inline-block;
- }
-
- .tab-btn.active {
- background: oklch(0.27 0.038 225.31);
- color: oklch(0.91 0.012 225.31);
- box-shadow: 2px 2px 0px oklch(0.42 0.04 225.31);
- }
-
- .tab-btn:hover {
- color: oklch(0.91 0.012 225.31);
- background: oklch(0.42 0.04 225.31);
- }
-
.stats-section {
- margin-bottom: 30px;
+ margin-bottom: var(--space-lg);
}
- .stats-section h2 {
- font-size: 11px;
- color: oklch(0.74 0.075 225.31);
- text-transform: uppercase;
- letter-spacing: 3px;
- margin: 0 0 10px 0;
- border-bottom: 1px solid oklch(0.42 0.04 225.31);
- padding-bottom: 5px;
+ .stats-table {
+ margin-bottom: var(--space-sm);
}
- .stat-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 5px 8px;
- margin-bottom: 3px;
+ .stats-count-header,
+ .stat-count {
+ text-align: right;
+ color: var(--fg-alt);
+ }
+
+ .stat-name-cell {
position: relative;
- border-radius: 2px;
overflow: hidden;
- font-size: 13px;
}
.stat-bar {
left: 0;
top: 0;
height: 100%;
- background: oklch(0.27 0.038 225.31);
- border-right: 1px solid oklch(0.42 0.04 225.31);
+ background-color: var(--bg-inactive);
z-index: 0;
}
.stat-name {
position: relative;
z-index: 1;
- color: oklch(0.91 0.012 225.31);
- flex: 1;
- padding-right: 10px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
}
- .stat-count {
- position: relative;
- z-index: 1;
- color: oklch(0.74 0.075 225.31);
- font-size: 12px;
- flex-shrink: 0;
- }
-
- .stats-empty {
- color: oklch(0.74 0.075 225.31);
- font-size: 13px;
- padding: 10px 0;
- }
-
- .cover-wrapper {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding-top: 5px;
- margin-left: 15px;
- gap: 4px;
- flex-shrink: 0;
- position: relative;
- }
-
- .genre-tag {
- display: none;
- }
-
-
-
.stat-row.clickable {
cursor: pointer;
}
.stat-row.clickable:hover .stat-name {
- color: oklch(0.70 0.035 225.31);
+ color: var(--blue);
}
- .filter-banner {
- display: flex;
- align-items: center;
- gap: 10px;
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- border-radius: 4px;
- padding: 8px 12px;
- margin-bottom: 12px;
- font-size: 13px;
- color: oklch(0.74 0.075 225.31);
+ .stats-empty {
+ color: var(--fg-alt);
+ font-size: 0.9em;
+ padding: var(--space-sm) 0;
}
- .filter-label {
- flex: 1;
- }
-
- .filter-label strong {
- color: oklch(0.91 0.012 225.31);
- }
-
- .filter-clear {
- background: none;
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.74 0.075 225.31);
- padding: 2px 8px;
- border-radius: 3px;
- cursor: pointer;
- font-family: inherit;
- font-size: 12px;
- }
-
- .filter-clear:hover {
- color: oklch(0.91 0.012 225.31);
- border-color: oklch(0.52 0.05 225.31);
- background: oklch(0.32 0.045 225.31);
- }
-
- .show-all-btn {
- background: none;
- border: none;
- color: oklch(0.74 0.075 225.31);
- cursor: pointer;
- font-family: inherit;
- font-size: 12px;
- padding: 4px 0;
- text-decoration: underline;
- }
-
- .show-all-btn:hover {
- color: oklch(0.91 0.012 225.31);
- background: oklch(0.32 0.045 225.31);
- }
-
.period-selector {
display: flex;
- gap: 6px;
- margin-bottom: 16px;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-md);
}
- .period-btn {
- background: none;
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.74 0.075 225.31);
- padding: 4px 12px;
- border-radius: 3px;
- cursor: pointer;
- font-family: inherit;
- font-size: 12px;
- }
-
- .period-btn:hover {
- color: oklch(0.91 0.012 225.31);
- border-color: oklch(0.7 0.1274 357.86);
- }
-
- .period-btn.active {
- background: oklch(0.5 0.1274 357.86);
- border-color: oklch(0.7 0.1274 357.86);
- color: oklch(0.97 0.01 357.86);
- }
-
.custom-range {
display: flex;
- gap: 8px;
- margin-top: 8px;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
align-items: center;
+ margin-bottom: var(--space-md);
}
.custom-range-input {
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.91 0.012 225.31);
- padding: 4px 8px;
- border-radius: 3px;
- font-family: inherit;
- font-size: 12px;
- width: 220px;
+ width: 16em;
}
- .custom-range-input::placeholder {
- color: oklch(0.74 0.075 225.31);
- opacity: 0.7;
- }
-
- .custom-range-input:focus {
- outline: none;
- border-color: oklch(0.91 0.012 225.31);
- }
-
.custom-range-input.error {
- border-color: oklch(0.68 0.11 225.31);
+ border-color: var(--red);
}
.custom-range-error {
- color: oklch(0.68 0.11 225.31);
- font-size: 12px;
- margin-top: 4px;
+ color: var(--red);
+ font-size: 0.9em;
}
- .similar-btn {
- background: none;
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.74 0.075 225.31);
- padding: 2px 8px;
- border-radius: 3px;
+ .show-all-btn {
+ background-color: var(--bg-alt);
+ border: solid 0.1em var(--border);
+ color: var(--fg);
+ padding: var(--space-xs) var(--space-sm);
cursor: pointer;
- font-family: inherit;
- font-size: 10px;
- margin-top: 4px;
- transition: all 0.2s ease;
+ font: inherit;
}
- .similar-btn:hover {
- background: oklch(0.32 0.045 225.31);
- color: oklch(0.91 0.012 225.31);
- border-color: oklch(0.7 0.1274 357.86);
+ .show-all-btn:hover {
+ background-color: var(--highlight);
+ border-color: var(--cyan);
}
- .similar-btn.active {
- background: oklch(0.5 0.1274 357.86);
- color: oklch(0.97 0.01 357.86);
- border-color: oklch(0.7 0.1274 357.86);
- box-shadow: 2px 2px 0px oklch(0.7 0.1274 357.86);
- }
-
- .similar-panel {
- margin-top: 10px;
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- border-radius: 4px;
- padding: 10px;
- box-shadow: 2px 2px 0px oklch(0.42 0.04 225.31);
- }
-
- .similar-panel-header {
- font-size: 11px;
- color: oklch(0.74 0.075 225.31);
- text-transform: uppercase;
- letter-spacing: 1px;
- margin-bottom: 8px;
- font-weight: bold;
- }
-
- .similar-loading {
- color: oklch(0.74 0.075 225.31);
- font-size: 12px;
- text-align: center;
- padding: 8px;
- }
-
- .similar-error {
- color: oklch(0.78 0.085 225.31);
- font-size: 12px;
- text-align: center;
- padding: 8px;
- }
-
- .similar-empty {
- color: oklch(0.74 0.075 225.31);
- font-size: 12px;
- text-align: center;
- padding: 8px;
- }
-
- .similar-track {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 6px 8px;
- margin-bottom: 4px;
- background: oklch(0.27 0.038 225.31);
- border-radius: 3px;
- border-left: 2px solid oklch(0.42 0.04 225.31);
- }
-
- .similar-track:last-child {
- margin-bottom: 0;
- }
-
- .similar-track-info {
- flex: 1;
- min-width: 0;
- }
-
- .similar-track-name {
- font-size: 12px;
- color: oklch(0.91 0.012 225.31);
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .similar-track-artist {
- font-size: 11px;
- color: oklch(0.70 0.035 225.31);
- margin-top: 1px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .similar-score {
- font-size: 10px;
- color: oklch(0.74 0.075 225.31);
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- padding: 2px 6px;
- border-radius: 2px;
- margin-right: 8px;
- font-weight: 500;
- }
-
- .similar-link {
- color: oklch(0.74 0.075 225.31);
- text-decoration: none;
- font-size: 12px;
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- padding: 2px 6px;
- border-radius: 2px;
- transition: all 0.2s ease;
- }
-
- .similar-link:hover {
- color: oklch(0.78 0.13 357.86);
- background: oklch(0.42 0.04 225.31);
- }
-
- .tracks-with-similar {
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
-
- .track-with-similar-container {
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
-
- .track-item {
- list-style: none;
- padding: 0;
- margin: 0;
- }
-
- .track-item li {
- margin: 0;
- }
-
- .search-bar {
- display: flex;
- gap: 8px;
- margin-bottom: 12px;
- align-items: center;
- }
-
- .search-input {
- flex: 1;
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.91 0.012 225.31);
- padding: 6px 10px;
- border-radius: 4px;
- font-family: inherit;
- font-size: 13px;
- }
-
- .search-input::placeholder {
- color: oklch(0.74 0.075 225.31);
- opacity: 0.7;
- }
-
- .search-input:focus {
- outline: none;
- border-color: oklch(0.91 0.012 225.31);
- }
-
- .search-btn {
- background: oklch(0.32 0.045 225.31);
- border: 1px solid oklch(0.52 0.05 225.31);
- color: oklch(0.91 0.012 225.31);
- padding: 6px 12px;
- border-radius: 4px;
- cursor: pointer;
- font-family: inherit;
- font-size: 12px;
- }
-
- .search-btn:hover {
- background: oklch(0.42 0.05 225.31);
- border-color: oklch(0.52 0.05 225.31);
- }
-
- .search-bar:focus-within .search-btn {
- background: oklch(0.5 0.1274 357.86);
- border-color: oklch(0.7 0.1274 357.86);
- color: oklch(0.97 0.01 357.86);
- }
-
.about-lead {
- color: oklch(0.70 0.035 225.31);
- font-size: 13px;
- line-height: 1.8;
- margin: 0 0 30px 0;
+ color: var(--fg-alt);
+ font-size: 0.9em;
+ line-height: 1.6;
}
.about-link {
- color: oklch(0.74 0.075 225.31);
- text-decoration: underline;
+ color: var(--blue);
}
.about-link:hover {
- color: oklch(0.78 0.13 357.86);
+ color: var(--cyan);
}
.about-users {
}
.about-users li {
- background: none;
- border: none;
- border-radius: 0;
- padding: 4px 0;
- margin-bottom: 0;
- display: block;
- box-shadow: none;
+ padding: var(--space-xs) 0;
}
- .about-users a {
- color: oklch(0.74 0.075 225.31);
- text-decoration: none;
- font-size: 13px;
- }
-
- .about-users a:hover {
- color: oklch(0.78 0.13 357.86);
- text-decoration: underline;
- }
-
.reg-form {
- display: flex;
- flex-direction: column;
- gap: 12px;
- max-width: 480px;
- margin: 20px 0;
+ max-width: 32em;
}
.reg-field {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-md);
}
.reg-label {
- font-size: 12px;
- color: oklch(0.74 0.075 225.31);
+ color: var(--fg-alt);
+ font-size: 0.9em;
}
.reg-input {
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- color: oklch(0.91 0.012 225.31);
- padding: 6px 10px;
- border-radius: 4px;
- font-family: inherit;
- font-size: 13px;
+ width: 100%;
}
- .reg-input.error {
- border-color: oklch(0.62 0.17 25);
- }
-
- .reg-btn {
- background: oklch(0.32 0.045 225.31);
- border: 1px solid oklch(0.52 0.05 225.31);
- color: oklch(0.91 0.012 225.31);
- padding: 8px 14px;
- border-radius: 4px;
- font-family: inherit;
- font-size: 13px;
+ .reg-btn,
+ .approve-btn,
+ .deny-btn {
+ background-color: var(--bg-alt);
+ border: solid 0.1em var(--border);
+ color: var(--fg);
+ padding: var(--space-xs) var(--space-sm);
+ font: inherit;
cursor: pointer;
- align-self: flex-start;
}
- .reg-btn:hover {
- background: oklch(0.38 0.05 225.31);
+ .reg-btn:hover,
+ .approve-btn:hover,
+ .deny-btn:hover {
+ background-color: var(--highlight);
+ border-color: var(--cyan);
}
- .reg-btn:disabled {
- opacity: 0.5;
- cursor: default;
+ .approve-btn {
+ background-color: var(--green-light);
+ border-color: var(--green);
+ color: var(--fg);
}
+ .approve-btn:hover {
+ background-color: var(--green);
+ border-color: var(--green);
+ color: var(--bg);
+ }
+
+ .deny-btn {
+ background-color: var(--red-light);
+ border-color: var(--red);
+ color: var(--fg);
+ }
+
+ .deny-btn:hover {
+ background-color: var(--red);
+ border-color: var(--red);
+ color: var(--bg);
+ }
+
.reg-error {
- color: oklch(0.72 0.15 25);
- font-size: 13px;
+ color: var(--red);
+ font-size: 0.9em;
}
.reg-success {
- color: oklch(0.78 0.13 150);
- font-size: 13px;
+ color: var(--green);
+ font-size: 0.9em;
}
.admin-list {
list-style: none;
padding: 0;
- margin: 16px 0;
- display: flex;
- flex-direction: column;
- gap: 10px;
+ margin: var(--space-sm) 0 var(--space-md) 0;
}
.admin-item {
- background: oklch(0.24 0.035 225.31);
- border: 1px solid oklch(0.36 0.04 225.31);
- border-radius: 6px;
- padding: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-sm) var(--space-md);
+ border: solid 0.1em var(--border);
+ background-color: var(--bg-alt);
}
- .admin-item-meta {
- font-size: 13px;
- margin-bottom: 8px;
- }
-
.admin-item-slug {
- font-weight: 700;
- color: oklch(0.88 0.05 225.31);
+ font-weight: bold;
+ color: var(--fg);
}
.admin-item-sub {
- color: oklch(0.7 0.05 225.31);
- font-size: 12px;
+ color: var(--fg-alt);
+ font-size: 0.9em;
}
- .admin-actions {
- display: flex;
- gap: 8px;
- }
-
- .approve-btn {
- background: oklch(0.34 0.07 150);
- border: 1px solid oklch(0.5 0.09 150);
- color: oklch(0.94 0.02 150);
- padding: 5px 12px;
- border-radius: 4px;
- font-family: inherit;
- font-size: 12px;
- cursor: pointer;
- }
-
- .deny-btn {
- background: oklch(0.32 0.07 25);
- border: 1px solid oklch(0.5 0.1 25);
- color: oklch(0.94 0.02 25);
- padding: 5px 12px;
- border-radius: 4px;
- font-family: inherit;
- font-size: 12px;
- cursor: pointer;
- }
-
.token-box {
- background: oklch(0.27 0.038 225.31);
- border: 1px solid oklch(0.42 0.04 225.31);
- border-radius: 4px;
- padding: 10px;
- margin: 12px 0;
- font-size: 12px;
+ border: solid 0.1em var(--border);
+ padding: var(--space-sm);
+ margin: var(--space-sm) 0;
+ background-color: var(--bg-alt);
+ font-size: 0.85em;
word-break: break-all;
+ color: var(--fg);
}
+
+ ::selection {
+ background-color: var(--highlight);
+ color: var(--fg-intense);
+ }
</style>
</head>
<body>
<div id="app"></div>
<script src="/client.js"></script>
<script>
- var userSlug = '""" <> userSlug
- <>
+ var userSlug = '""" <> userSlug <>
"""';
- var allUsers = """
- <> usersJson
- <>
+ var allUsers = """ <> usersJson <>
""";
- var registrationEnabled = """
- <> registrationEnabledJson
- <>
+ var registrationEnabled = """ <> registrationEnabledJson <>
""";
var adminToken = window.localStorage.getItem('corpusAdminToken') || '';
var app = Elm.Client.init({
blob - 7b4a00c9d4f0fec9c01568dbfaa534371735cc84
blob + 8cc781c6abb7e2cb96b5ba0337fc7d90d8794a6d
--- src/Types.elm
+++ src/Types.elm
, allUsers : List UserInfo
, searchInput : String
, activeSearch : Maybe String
- , tabsVisible : Bool
, page : Page
, registrationEnabled : Bool
| NextPage
| PrevPage
| SwitchTab Tab
- | ToggleTabs
| SetStatsPeriod Period
| OpenCustomInput
| UpdateCustomInput String
| ClearFilter
| HoverCover Int
| UnhoverCover
+ | CoverBackgroundClick
| ExpandSection String
| ShowAllSection String
| CollapseSection String
blob - c7564af396d76e666866be86292c0b91b173198b
blob + e53876ca46d78093bcca958c0d6192f5984e0045
--- src/View.elm
+++ src/View.elm
import Browser
import Dict exposing (Dict)
-import Html exposing (Html, a, button, div, h2, img, input, li, p, span, strong, text, ul)
+import Html exposing (Html, a, button, div, h1, h2, header, img, input, li, p, span, strong, table, tbody, td, text, th, thead, tr, ul)
import Html.Attributes as Attr exposing (class, disabled, href, placeholder, src, style, target, type_, value)
import Html.Events exposing (on, onClick, onInput)
import Json.Decode as D
{ title = "scrobbler"
, body =
[ div [ class "container" ]
- (case model.page of
- RegisterPage ->
- [ renderRegister model ]
+ (header [] [ renderNav model ]
+ :: (case model.page of
+ RegisterPage ->
+ [ renderRegister model ]
- AdminPage ->
- [ renderAdmin model ]
+ AdminPage ->
+ [ renderAdmin model ]
- MainPage ->
- [ renderMain model ]
+ MainPage ->
+ [ renderMain model ]
+ )
)
]
}
+renderNav : Model -> Html Msg
+renderNav model =
+ let
+ navClass : Bool -> Html.Attribute Msg
+ navClass active =
+ class
+ (if active then
+ "tab-btn active"
+
+ else
+ "tab-btn"
+ )
+
+ navLink : String -> Bool -> String -> Html Msg
+ navLink label active href_ =
+ a [ navClass active, href href_ ] [ text label ]
+
+ navTab : String -> Tab -> Html Msg
+ navTab label tab =
+ if model.page == MainPage then
+ button [ navClass (model.activeTab == tab), onClick (SwitchTab tab) ] [ text label ]
+
+ else
+ navLink label False "/"
+ in
+ div [ class "bar" ]
+ [ navTab "listens" ListensTab
+ , navTab "stats" StatsTab
+ , navTab "about" AboutTab
+ , if model.registrationEnabled then
+ navLink "join" (model.page == RegisterPage) "/register"
+
+ else
+ text ""
+ ]
+
+
renderMain : Model -> Html Msg
renderMain model =
div []
- [ if model.tabsVisible then
- div [ class "tabs" ]
- [ a
- [ class
- ("tab-btn"
- ++ (if model.activeTab == ListensTab then
- " active"
-
- else
- ""
- )
- )
- , href
- (if model.userSlug == "" then
- "/"
-
- else
- "/u/" ++ model.userSlug
- )
- ]
- [ text "listens" ]
- , button
- [ class
- ("tab-btn"
- ++ (if model.activeTab == StatsTab then
- " active"
-
- else
- ""
- )
- )
- , onClick (SwitchTab StatsTab)
- ]
- [ text "stats" ]
- , if model.registrationEnabled then
- a [ class "tab-btn", href "/register" ] [ text "join" ]
-
- else
- text ""
- , button
- [ class
- ("tab-btn"
- ++ (if model.activeTab == AboutTab then
- " active"
-
- else
- ""
- )
- )
- , onClick (SwitchTab AboutTab)
- ]
- [ text "about" ]
- ]
-
- else
- text ""
- , case model.activeTab of
- ListensTab ->
- div []
- [ div [ class "search-bar" ]
- [ input
- [ type_ "text"
- , class "search-input"
- , placeholder "search by track, artist, album or label"
- , value model.searchInput
- , onInput UpdateSearchInput
- , onEnter SubmitSearch
- ]
- []
- , button [ class "search-btn", onClick SubmitSearch ] [ text "search" ]
- , case model.activeSearch of
- Just _ ->
- button [ class "filter-clear", onClick ClearSearch ] [ text "✕ clear" ]
-
- Nothing ->
- text ""
+ [ case model.activeTab of
+ ListensTab ->
+ div []
+ [ div [ class "bar" ]
+ [ input
+ [ type_ "search"
+ , class "search-input"
+ , placeholder ""
+ , value model.searchInput
+ , onInput UpdateSearchInput
+ , onEnter SubmitSearch
]
- , case model.activeFilter of
+ []
+ , button [ class "search-btn", onClick SubmitSearch ] [ text "search" ]
+ , case model.activeSearch of
+ Just _ ->
+ button [ class "filter-clear", onClick ClearSearch ] [ text "✕ clear" ]
+
Nothing ->
text ""
+ ]
+ , case model.activeFilter of
+ Nothing ->
+ text ""
- Just { field, value } ->
- div [ class "filter-banner" ]
- [ span [ class "filter-label" ]
- [ text (field ++ ": ")
- , strong [] [ text value ]
- ]
- , button [ class "filter-clear", onClick ClearFilter ]
- [ text "✕ clear" ]
+ Just { field, value } ->
+ div [ class "filter-banner" ]
+ [ span [ class "filter-label" ]
+ [ text (field ++ ": ")
+ , strong [] [ text value ]
]
- , renderContent model
- , div [ class "pagination" ]
- [ button
- [ class "page-btn"
- , disabled (model.offset == 0 || model.loading)
- , onClick PrevPage
+ , button [ class "filter-clear", onClick ClearFilter ]
+ [ text "✕ clear" ]
]
- [ text "Previous" ]
- , div [ class "page-indicator" ]
- [ text ("Page " ++ String.fromInt (model.offset // model.limit + 1)) ]
- , button
- [ class "page-btn"
- , disabled (List.length model.listens < model.limit || model.loading)
- , onClick NextPage
- ]
- [ text "Next" ]
+ , renderContent model
+ , renderCoverOverlay model.hoveredCover
+ , div [ class "pagination" ]
+ [ button
+ [ class "page-btn"
+ , disabled (model.offset == 0 || model.loading)
+ , onClick PrevPage
]
+ [ text "Previous" ]
+ , div [ class "page-indicator" ]
+ [ text ("Page " ++ String.fromInt (model.offset // model.limit + 1)) ]
+ , button
+ [ class "page-btn"
+ , disabled (List.length model.listens < model.limit || model.loading)
+ , onClick NextPage
+ ]
+ [ text "Next" ]
]
+ ]
- StatsTab ->
- div []
- [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError
- , renderStatsView model.expandedSections model.loadedSections model.stats
- ]
+ StatsTab ->
+ div []
+ [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError
+ , renderStatsView model.expandedSections model.loadedSections model.stats
+ ]
- AboutTab ->
- renderAboutView model.userSlug model.allUsers
- ]
+ AboutTab ->
+ renderAboutView model.userSlug model.allUsers
+ ]
+renderCoverOverlay : Maybe Int -> Html Msg
+renderCoverOverlay hovered =
+ case hovered of
+ Nothing ->
+ text ""
+
+ Just _ ->
+ div [ class "cover-overlay", onClick CoverBackgroundClick ] []
+
+
renderContent : Model -> Html Msg
renderContent model =
if model.loading && List.isEmpty model.listens then
- ul [] [ li [ class "loading" ] [ text "⏳" ] ]
+ div [ class "loading" ] [ text "⏳" ]
else
case model.error of
Just err ->
- ul [] [ li [ class "error" ] [ text err ] ]
+ div [ class "error" ] [ text err ]
Nothing ->
- div [ class "tracks-with-similar" ]
- (List.indexedMap
- (\idx listen ->
- let
- artist =
- Maybe.withDefault "" listen.artistName
+ table []
+ [ tbody []
+ (List.indexedMap
+ (\idx listen ->
+ let
+ artist =
+ Maybe.withDefault "" listen.artistName
- trackName =
- Maybe.withDefault "" listen.trackName
+ trackName =
+ Maybe.withDefault "" listen.trackName
- key =
- String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ trackName
- in
- div [ class "track-with-similar-container" ]
- [ ul [ class "track-item" ]
- [ renderListen model.userSlug model.currentTime model.failedCovers model.hoveredCover model.similarStates idx listen ]
- , renderSimilarPanel model.similarStates key
- ]
+ key =
+ String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ trackName
+ in
+ renderListen model.userSlug model.currentTime model.failedCovers model.hoveredCover model.similarStates idx listen
+ :: renderSimilarRow key model.similarStates
+ )
+ model.listens
+ |> List.concat
)
- model.listens
- )
+ ]
renderListen : String -> Maybe Time.Posix -> Set String -> Maybe Int -> Dict String SimilarState -> Int -> Listen -> Html Msg
isActive =
Dict.member key similarStates
in
- li [ class "success" ]
- [ div [ class "track-info" ]
- [ div [ class "track-name" ]
- [ button [ class "track-link", onClick (FilterBy "track" (Maybe.withDefault "Unknown Track" listen.trackName)) ] [ text (Maybe.withDefault "Unknown Track" listen.trackName) ] ]
- , div [ class "track-artist" ] [ button [ class "artist-link", onClick (FilterBy "artist" artist) ] [ text artist ] ]
- , div [ class "track-time" ]
- [ span []
+ tr [ class "listen-row" ]
+ [ td [ class "d" ]
+ [ if Set.member coverUrl failedCovers then
+ text ""
+
+ else
+ div [ class "cover-cell" ]
+ [ img
+ [ class
+ ("track-cover"
+ ++ (if isZoomed then
+ " zoomed"
+
+ else
+ ""
+ )
+ )
+ , src coverUrl
+ , Attr.alt release
+ , on "error" (D.succeed (ImageError coverUrl))
+ , onClick
+ (if isZoomed then
+ UnhoverCover
+
+ else
+ HoverCover idx
+ )
+ ]
+ []
+ ]
+ ]
+ , td [ class "track-cell" ]
+ [ div [ class "track-line" ]
+ [ if artist /= "" && trackName /= "" then
+ button
+ [ class
+ ("similar-btn"
+ ++ (if isActive then
+ " active"
+
+ else
+ ""
+ )
+ )
+ , onClick (FetchSimilar idx artist trackName)
+ ]
+ [ text "similar" ]
+
+ else
+ text ""
+ , button [ class "track-link", onClick (FilterBy "track" (Maybe.withDefault "Unknown Track" listen.trackName)) ] [ text (Maybe.withDefault "Unknown Track" listen.trackName) ]
+ ]
+ , div [ class "track-artist" ]
+ [ button [ class "artist-link", onClick (FilterBy "artist" artist) ] [ text artist ] ]
+ , div [ class "track-meta" ]
+ [ div [ class "track-release" ]
(button [ class "album-link", onClick (FilterBy "album" release) ] [ text release ]
:: (case listen.label of
Just l ->
Nothing ->
[]
)
- ++ [ text (" • " ++ timeAgo currentTime listen.listenedAt) ]
)
+ , case listen.genre of
+ Just g ->
+ div [ class "genre-tag" ] [ text g ]
+
+ Nothing ->
+ text ""
]
- ]
- , div [ class "cover-wrapper" ]
- [ if Set.member coverUrl failedCovers then
- text ""
-
- else
- img
- [ class
- ("track-cover"
- ++ (if isZoomed then
- " zoomed"
-
- else
- ""
- )
- )
- , src coverUrl
- , Attr.alt release
- , on "error" (D.succeed (ImageError coverUrl))
- , onClick
- (if isZoomed then
- UnhoverCover
-
- else
- HoverCover idx
- )
- ]
- []
- , case listen.genre of
- Just g ->
- div [ class "genre-tag" ] [ text g ]
-
- Nothing ->
+ , div [ class "cover-wrapper" ]
+ [ if Set.member coverUrl failedCovers then
text ""
- , if artist /= "" && trackName /= "" then
- button
- [ class
- ("similar-btn"
- ++ (if isActive then
- " active"
- else
- ""
- )
- )
- , onClick (FetchSimilar idx artist trackName)
- ]
- [ text "similar" ]
+ else
+ img
+ [ class
+ ("track-cover"
+ ++ (if isZoomed then
+ " zoomed"
- else
- text ""
+ else
+ ""
+ )
+ )
+ , src coverUrl
+ , Attr.alt release
+ , on "error" (D.succeed (ImageError coverUrl))
+ , onClick
+ (if isZoomed then
+ UnhoverCover
+
+ else
+ HoverCover idx
+ )
+ ]
+ []
+ ]
]
]
-renderSimilarPanel : Dict String SimilarState -> String -> Html Msg
-renderSimilarPanel similarStates key =
+renderSimilarRow : String -> Dict String SimilarState -> List (Html Msg)
+renderSimilarRow key similarStates =
case Dict.get key similarStates of
Nothing ->
- text ""
+ []
Just SimilarLoading ->
- div [ class "similar-panel" ]
- [ div [ class "similar-loading" ] [ text "⏳" ] ]
+ [ tr [ class "similar-row" ]
+ [ td [ class "d" ] []
+ , td []
+ [ div [ class "similar-panel" ]
+ [ div [ class "similar-loading" ] [ text "⏳" ] ]
+ ]
+ ]
+ ]
Just (SimilarError err) ->
- div [ class "similar-panel" ]
- [ div [ class "similar-error" ] [ text err ] ]
+ [ tr [ class "similar-row" ]
+ [ td [ class "d" ] []
+ , td []
+ [ div [ class "similar-panel" ]
+ [ div [ class "similar-error" ] [ text err ] ]
+ ]
+ ]
+ ]
Just (SimilarLoaded tracks) ->
- div [ class "similar-panel" ]
- [ if List.isEmpty tracks then
- div [ class "similar-empty" ] [ text "no similar songs found" ]
+ [ tr [ class "similar-row" ]
+ [ td [ class "d" ] []
+ , td []
+ [ div [ class "similar-panel" ]
+ [ if List.isEmpty tracks then
+ div [ class "similar-empty" ] [ text "no similar songs found" ]
- else
- div [] (List.map renderSimilarTrack tracks)
+ else
+ div [] (List.map renderSimilarTrack tracks)
+ ]
+ ]
]
+ ]
renderSimilarTrack : SimilarTrack -> Html Msg
, case track.videoUri of
Just link ->
a [ class "similar-link", href link, target "_blank" ]
- [ text "▶" ]
+ [ text "play" ]
Nothing ->
text ""
div [ class "stats-empty" ] [ text "beyond here lies nothing" ]
else
- ul [] (List.map (renderStatEntry maxCount mField) visible)
+ table [ class "stats-table" ]
+ [ thead []
+ [ tr []
+ [ th [] [ text "name" ]
+ , th [ class "stats-count-header" ] [ text "plays" ]
+ ]
+ ]
+ , tbody [] (List.map (renderStatEntry maxCount mField) visible)
+ ]
, footer
]
Nothing ->
[ class "stat-row" ]
in
- li rowAttrs
- [ div [ class "stat-bar", style "width" (String.fromInt barPct ++ "%") ] []
- , span [ class "stat-name" ] [ text entry.name ]
- , span [ class "stat-count" ] [ text (String.fromInt entry.count) ]
+ tr rowAttrs
+ [ td [ class "stat-name-cell" ]
+ [ div [ class "stat-bar", style "width" (String.fromInt barPct ++ "%") ] []
+ , span [ class "stat-name" ] [ text entry.name ]
+ ]
+ , td [ class "stat-count" ] [ text (String.fromInt entry.count) ]
]