Compare commits
56 Commits
Author | SHA1 | Date |
---|---|---|
Bauke | cda4cba1bf | |
Bauke | a830007ff2 | |
Bauke | 8b5d8c047b | |
Bauke | c9db2e7b30 | |
Bauke | 44b7fdbcb0 | |
Bauke | e2215c8a3c | |
Bauke | 0c56e3f368 | |
Bauke | 4bddf6c9f7 | |
Bauke | 37dfcfc1f0 | |
Bauke | 9cd91dc788 | |
Bauke | 537c8c0f3e | |
Bauke | 2e828eb087 | |
Bauke | cb2e5e9f17 | |
Bauke | 7129322105 | |
Bauke | 2a4f6f2c48 | |
Bauke | 9f0f5a46b7 | |
Bauke | 1a53885993 | |
Bauke | 8d50aee844 | |
Bauke | 85469eabf7 | |
Bauke | 6d121fb7b4 | |
Bauke | 279f40004f | |
Bauke | cc98bd87f9 | |
Bauke | ebfae2069d | |
Bauke | 7b881c6162 | |
Bauke | 61e59cb88c | |
Bauke | 24640cce06 | |
Bauke | f47b53b928 | |
Bauke | e48d500a0c | |
Bauke | ac9acff203 | |
Bauke | fb47a795ec | |
Bauke | 57f3b64958 | |
Bauke | ff4e486455 | |
Bauke | 2762395adb | |
Bauke | af0a0bc71b | |
Bauke | cdc61576c1 | |
Bauke | 0b2026daf3 | |
Bauke | d9895d7b87 | |
Bauke | 18be96943e | |
Bauke | 421c64cd49 | |
Bauke | 71331a6ee0 | |
Bauke | bc02927d8b | |
Bauke | 25c45d9d01 | |
Bauke | 98034ae621 | |
Bauke | 521de9ec70 | |
Bauke | 09656ee76c | |
Bauke | a3761eb2bb | |
Bauke | 319a352d2e | |
Bauke | d4d9a8b0f0 | |
Bauke | 70746f4079 | |
Bauke | 2f5c75a68f | |
Bauke | 7e59bfbb6b | |
Bauke | 0b9b9fa162 | |
Bauke | aeb3baaca2 | |
Bauke | e3faf966df | |
Bauke | 67457393d6 | |
Bauke | b0a3b960cc |
|
@ -1,107 +1,3 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
.direnv/
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Site output directory
|
||||
/public/
|
||||
public/
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
# href+
|
||||
|
||||
> https://href.plus
|
||||
> **Song-linking website using MusicBrainz data.**
|
||||
|
||||
* If you find a release that has a link which just shows the domain and not a proper name, please post it in [#5](https://github.com/Bauke/href-plus/issues/5). Thank you!
|
||||
## License
|
||||
|
||||
Distributed under the [GPL-3.0-or-later](https://spdx.org/licenses/GPL-3.0-or-later.html) license, see [LICENSE](https://git.bauke.xyz/Bauke/href-plus/src/branch/main/LICENSE) for more information.
|
||||
|
||||
Icons in [`source/assets/icons`](source/assets/icons) belong to their respective owners.
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1705309234,
|
||||
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1705883077,
|
||||
"narHash": "sha256-ByzHHX3KxpU1+V0erFy8jpujTufimh6KaS/Iv3AciHk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5f5210aa20e343b7e35f40c033000db0ef80d7b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
devShells.default = import ./shell.nix { inherit pkgs; };
|
||||
}
|
||||
);
|
||||
}
|
43
package.json
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"name": "href-plus",
|
||||
"version": "0.1.0",
|
||||
"description": "Song-linking website using MusicBrainz data.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"version": "0.1.0",
|
||||
"author": "Bauke <me@bauke.xyz>",
|
||||
"repository": "https://git.bauke.xyz/Bauke/href-plus",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"test": "xo && stylelint 'source/**/*.scss' && tsc",
|
||||
|
@ -10,32 +13,36 @@
|
|||
"deploy:netlify": "netlify deploy --prod -d 'public' -s 'href.plus'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^4.5.1",
|
||||
"htm": "^3.1.0",
|
||||
"modern-normalize": "^1.1.0",
|
||||
"preact": "^10.6.4",
|
||||
"preact-router": "^3.2.1"
|
||||
"@fontsource/inter": "^5.0.16",
|
||||
"htm": "^3.1.1",
|
||||
"modern-normalize": "^2.0.0",
|
||||
"preact": "^10.19.3",
|
||||
"preact-router": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.5",
|
||||
"postcss": "^8.4.5",
|
||||
"sass": "^1.45.1",
|
||||
"stylelint": "^14.2.0",
|
||||
"stylelint-config-standard-scss": "^3.0.0",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.7.10",
|
||||
"xo": "^0.47.0"
|
||||
"@types/node": "^20.11.5",
|
||||
"netlify-cli": "^17.15.1",
|
||||
"postcss": "^8.4.33",
|
||||
"sass": "^1.70.0",
|
||||
"stylelint": "^16.2.0",
|
||||
"stylelint-config-standard-scss": "^13.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12",
|
||||
"xo": "^0.56.0"
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss"
|
||||
],
|
||||
"rules": {
|
||||
"string-quotes": "single"
|
||||
}
|
||||
]
|
||||
},
|
||||
"xo": {
|
||||
"prettier": true,
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "off",
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off",
|
||||
"n/file-extension-in-import": "off"
|
||||
},
|
||||
"space": true
|
||||
}
|
||||
}
|
||||
|
|
10583
pnpm-lock.yaml
|
@ -0,0 +1,7 @@
|
|||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
with pkgs;
|
||||
|
||||
mkShell rec {
|
||||
packages = [ cargo-make nodejs nodePackages.pnpm ];
|
||||
}
|
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 7.6 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 66 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 31 KiB |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 15 KiB |
|
@ -0,0 +1,3 @@
|
|||
User-agent: *
|
||||
Disallow: /release/
|
||||
Disallow: /settings
|
|
@ -1,19 +1,21 @@
|
|||
.search-bar {
|
||||
max-width: 70rem;
|
||||
|
||||
input[type='text'] {
|
||||
background-color: var(--background-2);
|
||||
border: 1px solid var(--foreground-1);
|
||||
border: var(--border-size) solid var(--foreground-1);
|
||||
color: inherit;
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul {
|
||||
border: 1px solid var(--foreground-1);
|
||||
border: var(--border-size) solid var(--foreground-1);
|
||||
border-top: none;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
@ -45,4 +47,17 @@
|
|||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
background-color: var(--background-1);
|
||||
border: var(--border-size) solid var(--foreground-1);
|
||||
color: var(--foreground-1);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--foreground-1);
|
||||
color: var(--background-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
.explainer {
|
||||
background-color: var(--background-2);
|
||||
border: var(--border-size) solid var(--foreground-2);
|
||||
margin-top: 1rem;
|
||||
max-width: 70rem;
|
||||
padding: 1rem;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
.release {
|
||||
background-color: var(--background-2);
|
||||
box-shadow: 0 0 1rem #000;
|
||||
border: var(--border-size) solid var(--foreground-2);
|
||||
margin: 2rem auto;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
@ -39,15 +39,16 @@
|
|||
}
|
||||
|
||||
.divider {
|
||||
border-top: 2px solid var(--background-1);
|
||||
border-top: var(--border-size) solid var(--background-1);
|
||||
}
|
||||
}
|
||||
|
||||
.release-link {
|
||||
a {
|
||||
align-items: center;
|
||||
background-color: var(--background-1);
|
||||
border-radius: var(--border-radius);
|
||||
display: block;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
text-decoration: none;
|
||||
|
||||
|
@ -55,4 +56,14 @@
|
|||
background-color: var(--foreground-1);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: var(--border-radius);
|
||||
height: 3rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.release-date {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
.settings-page {
|
||||
padding: 1rem;
|
||||
|
||||
.setting {
|
||||
background-color: var(--background-2);
|
||||
border: var(--border-size) solid var(--foreground-2);
|
||||
margin-top: 1rem;
|
||||
max-width: 70rem;
|
||||
padding: 1rem;
|
||||
|
||||
h2,
|
||||
select {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,13 @@
|
|||
@use 'common';
|
||||
|
||||
// Theme definitions
|
||||
@use 'themes/default';
|
||||
@use 'themes/dracula';
|
||||
@use 'themes/gruvbox';
|
||||
@use 'themes/high-contrast';
|
||||
@use 'themes/love';
|
||||
@use 'themes/monokai';
|
||||
@use 'themes/solarized';
|
||||
|
||||
// Component styling
|
||||
@use 'components/search-bar';
|
||||
|
@ -12,3 +18,4 @@
|
|||
@use 'pages/home';
|
||||
@use 'pages/not-found';
|
||||
@use 'pages/release';
|
||||
@use 'pages/settings';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
body {
|
||||
--border-radius: 8px;
|
||||
--border-size: 2px;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
.dracula {
|
||||
--foreground-1: #f8f8f2;
|
||||
--foreground-2: #f8f8f2;
|
||||
--background-1: #282a36;
|
||||
--background-2: #44475a;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.gruvbox-dark {
|
||||
--foreground-1: #fbf1c7;
|
||||
--foreground-2: #ebdbb2;
|
||||
--background-1: #282828;
|
||||
--background-2: #3c3836;
|
||||
}
|
||||
|
||||
.gruvbox-light {
|
||||
--foreground-1: #282828;
|
||||
--foreground-2: #3c3836;
|
||||
--background-1: #fbf1c7;
|
||||
--background-2: #ebdbb2;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.high-contrast-black {
|
||||
--foreground-1: #fff;
|
||||
--foreground-2: #ddd;
|
||||
--background-1: #000;
|
||||
--background-2: #222;
|
||||
}
|
||||
|
||||
.high-contrast-white {
|
||||
--foreground-1: #000;
|
||||
--foreground-2: #222;
|
||||
--background-1: #fff;
|
||||
--background-2: #ddd;
|
||||
}
|
|
@ -1,9 +1,15 @@
|
|||
body,
|
||||
.love-dark {
|
||||
--border-radius: 8px;
|
||||
--border-size: 2px;
|
||||
--foreground-1: #f2efff;
|
||||
--foreground-2: #e6deff;
|
||||
--background-1: #1f1731;
|
||||
--background-2: #2a2041;
|
||||
--accent-1: #d2b83a;
|
||||
}
|
||||
|
||||
.love-light {
|
||||
--foreground-1: #1f1731;
|
||||
--foreground-2: #2a2041;
|
||||
--background-1: #f2efff;
|
||||
--background-2: #e6deff;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
@use 'sass:color';
|
||||
|
||||
.monokai {
|
||||
--foreground-1: #d6d6d6;
|
||||
--foreground-2: #d6d6d6;
|
||||
--background-1: #{color.scale(#2e2e2e, $lightness: -25%)};
|
||||
--background-2: #2e2e2e;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.solarized-dark {
|
||||
--foreground-1: #839496;
|
||||
--foreground-2: #93a1a1;
|
||||
--background-1: #002b36;
|
||||
--background-2: #073642;
|
||||
}
|
||||
|
||||
.solarized-light {
|
||||
--foreground-1: #657b83;
|
||||
--foreground-2: #586e75;
|
||||
--background-1: #fdf6e3;
|
||||
--background-2: #eee8d5;
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import debounce from '../utilities/debounce.js';
|
||||
import searchReleases, {SearchResult} from '../utilities/search.js';
|
||||
import searchReleases, {
|
||||
searchLimit,
|
||||
SearchResult,
|
||||
} from '../utilities/search.js';
|
||||
|
||||
type Props = Record<string, unknown>;
|
||||
|
||||
|
@ -24,6 +26,23 @@ export default class SearchBar extends Component<Props, State> {
|
|||
};
|
||||
}
|
||||
|
||||
searchMore = async (): Promise<void> => {
|
||||
if (this.state.searchValue.length === 0) {
|
||||
this.setState({searchResults: [], searchState: 'waiting'});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
searchResults: [
|
||||
...this.state.searchResults,
|
||||
...(await searchReleases(
|
||||
this.state.searchValue,
|
||||
this.state.searchResults.length,
|
||||
)),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
searchQuery = async (): Promise<void> => {
|
||||
if (this.state.searchValue.length === 0) {
|
||||
this.setState({searchResults: [], searchState: 'waiting'});
|
||||
|
@ -54,16 +73,14 @@ export default class SearchBar extends Component<Props, State> {
|
|||
`;
|
||||
}
|
||||
|
||||
results.push(
|
||||
html`
|
||||
results.push(html`
|
||||
<li>
|
||||
<a class="search-result" href="/release/${result.id}">
|
||||
<span class="display">${result.artist} - ${result.title}</span>
|
||||
${disambiguation}
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
const isLoading = this.state.searchState === 'searching';
|
||||
|
@ -79,6 +96,17 @@ export default class SearchBar extends Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
const resultAmount = this.state.searchResults.length;
|
||||
if (resultAmount > 0 && resultAmount % searchLimit === 0) {
|
||||
results.push(html`
|
||||
<li class="search-state">
|
||||
<button class="load-more" onClick=${this.searchMore}>
|
||||
Load more…
|
||||
</button>
|
||||
</li>
|
||||
`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="search-bar">
|
||||
<input
|
||||
|
|
|
@ -1,16 +1,32 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import ExternalAnchor from './external-anchor.js';
|
||||
|
||||
export default class SharedFooter extends Component {
|
||||
type Props = {
|
||||
page: string;
|
||||
};
|
||||
|
||||
export default class SharedFooter extends Component<Props> {
|
||||
render() {
|
||||
const githubUrl = 'https://github.com/Bauke/href-plus';
|
||||
const {page} = this.props;
|
||||
|
||||
const homeLink =
|
||||
page === 'home' || page === 'not-found'
|
||||
? undefined
|
||||
: html`<a href="/">Home</a>${' '}`;
|
||||
|
||||
const settingsLink =
|
||||
page === 'settings'
|
||||
? undefined
|
||||
: html`<a href="/settings">Settings</a>${' '}`;
|
||||
|
||||
const giteaUrl = 'https://git.bauke.xyz/Bauke/href-plus';
|
||||
const versionText = `v${hrefPlusVersion}/${hrefPlusCommitHash}`;
|
||||
const versionUrl = `${githubUrl}/tree/${hrefPlusCommitHash}`;
|
||||
const versionUrl = `${giteaUrl}/src/commit/${hrefPlusCommitHash}`;
|
||||
|
||||
return html`
|
||||
<footer class="shared-footer">
|
||||
<${ExternalAnchor} text="GitHub" url=${githubUrl} />
|
||||
${homeLink}${settingsLink}
|
||||
<${ExternalAnchor} text="Gitea" url=${giteaUrl} />
|
||||
${' '}
|
||||
<${ExternalAnchor} text=${versionText} url=${versionUrl} />
|
||||
</footer>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import ExternalAnchor from '../components/external-anchor.js';
|
||||
import SearchBar from '../components/search-bar.js';
|
||||
import SharedFooter from '../components/shared-footer.js';
|
||||
|
@ -59,7 +58,7 @@ export default class HomePage extends Component {
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<${SharedFooter} />
|
||||
<${SharedFooter} page="home" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import SharedFooter from '../components/shared-footer.js';
|
||||
|
||||
export default class NotFoundPage extends Component {
|
||||
|
@ -13,7 +12,7 @@ export default class NotFoundPage extends Component {
|
|||
<a href="/">← Home</a>
|
||||
</main>
|
||||
|
||||
<${SharedFooter} />
|
||||
<${SharedFooter} page="not-found" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import ExternalAnchor from '../components/external-anchor.js';
|
||||
import Release from '../utilities/release.js';
|
||||
|
||||
|
@ -66,39 +65,50 @@ export default class ReleasePage extends Component<Props, State> {
|
|||
if (loading === 'finished' && release !== undefined) {
|
||||
document.title = release.display();
|
||||
|
||||
const date =
|
||||
release.date === undefined
|
||||
? undefined
|
||||
: html`<span class="release-date">${release.date}</span>`;
|
||||
|
||||
const image =
|
||||
release.image === undefined
|
||||
? undefined
|
||||
: html`<img class="cover-art" src="${release.image}" />`;
|
||||
|
||||
const urls = release.links.map(
|
||||
(link) =>
|
||||
html`
|
||||
const urls = release.links.map((link) => {
|
||||
let linkImage;
|
||||
if (link.icon !== undefined) {
|
||||
linkImage = html`<img src="/icons/${link.icon}" />`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<li class="release-link">
|
||||
<${ExternalAnchor} url="${link.original}" text="${link.text}" />
|
||||
<a href="${link.original}" rel="noopener noreferrer">
|
||||
${linkImage} ${link.text}
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
`;
|
||||
});
|
||||
|
||||
const releaseUrl = `https://musicbrainz.org/release/${mbid}`;
|
||||
if (urls.length === 0) {
|
||||
const editUrl = `${releaseUrl}/edit`;
|
||||
urls.push(
|
||||
html`
|
||||
urls.push(html`
|
||||
<li class="no-links">
|
||||
<p>
|
||||
There are no links for this release yet, consider${' '}
|
||||
<${ExternalAnchor} url="${editUrl}" text="adding some" />?
|
||||
</p>
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
`);
|
||||
} else {
|
||||
urls.push(
|
||||
html`<li class="divider"></li>`,
|
||||
html`
|
||||
<li class="release-link">
|
||||
<${ExternalAnchor} url="${releaseUrl}" text="MusicBrainz" />
|
||||
<a href="${releaseUrl}" rel="noopener noreferrer">
|
||||
<img src="/icons/musicbrainz.png" /> MusicBrainz
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
|
@ -109,6 +119,7 @@ export default class ReleasePage extends Component<Props, State> {
|
|||
<header class="release-header">
|
||||
${image}
|
||||
<h1>${release.artist}<br />${release.title}</h1>
|
||||
${date}
|
||||
</header>
|
||||
|
||||
<main class="release-main">
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
import ExternalAnchor from '../components/external-anchor.js';
|
||||
import SharedFooter from '../components/shared-footer.js';
|
||||
import {isDebugEnabled} from '../utilities/debug.js';
|
||||
import {
|
||||
defaultTheme,
|
||||
getThemeByCssClass,
|
||||
setTheme,
|
||||
themes,
|
||||
} from '../utilities/themes.js';
|
||||
|
||||
type Props = Record<string, unknown>;
|
||||
|
||||
type State = {
|
||||
debugChecked: boolean;
|
||||
selectedTheme: string;
|
||||
};
|
||||
|
||||
export default class SettingsPage extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
debugChecked: isDebugEnabled(),
|
||||
selectedTheme:
|
||||
window.localStorage.getItem('theme') ?? defaultTheme.cssClass,
|
||||
};
|
||||
}
|
||||
|
||||
onDebugChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
window.localStorage.setItem('debug', checked.toString());
|
||||
};
|
||||
|
||||
onThemeChange = (event: Event) => {
|
||||
const theme = getThemeByCssClass((event.target as HTMLSelectElement).value);
|
||||
setTheme(theme);
|
||||
this.setState({selectedTheme: theme.cssClass});
|
||||
};
|
||||
|
||||
render() {
|
||||
document.title = 'Settings';
|
||||
|
||||
const {selectedTheme} = this.state;
|
||||
const themeOptions = themes.map(
|
||||
(theme) => html`<option value=${theme.cssClass}>${theme.name}</option>`,
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="settings-page">
|
||||
<header>
|
||||
<h1>Settings</h1>
|
||||
</header>
|
||||
|
||||
<section class="setting">
|
||||
<h2>Theme</h2>
|
||||
|
||||
<select value=${selectedTheme} onChange=${this.onThemeChange}>
|
||||
${themeOptions}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="setting">
|
||||
<h2>Debug</h2>
|
||||
|
||||
<input
|
||||
checked=${this.state.debugChecked}
|
||||
id="debug-checkbox"
|
||||
name="debug-checkbox"
|
||||
onChange=${this.onDebugChange}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="debug-checkbox">
|
||||
Log debug information to the console.
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<${SharedFooter} page="settings" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -2,21 +2,30 @@
|
|||
// import 'preact/debug';
|
||||
|
||||
import '@fontsource/inter/latin.css';
|
||||
|
||||
import {html, render} from 'htm/preact';
|
||||
import {Router} from 'preact-router';
|
||||
|
||||
import HomePage from './pages/home.js';
|
||||
import NotFoundPage from './pages/not-found.js';
|
||||
import ReleasePage from './pages/release.js';
|
||||
import SettingsPage from './pages/settings.js';
|
||||
import {getThemeByCssClass, themeContext} from './utilities/themes.js';
|
||||
|
||||
const activeTheme = getThemeByCssClass(
|
||||
window.localStorage.getItem('theme') ?? '',
|
||||
);
|
||||
|
||||
document.body.classList.value = activeTheme.cssClass;
|
||||
|
||||
render(
|
||||
html`
|
||||
<${themeContext.Provider} value=${activeTheme}>
|
||||
<${Router}>
|
||||
<${HomePage} path="/" />
|
||||
<${SettingsPage} path="/settings" />
|
||||
<${ReleasePage} path="/release/:mbid" />
|
||||
<${NotFoundPage} default />
|
||||
<//>
|
||||
<//>
|
||||
`,
|
||||
document.body,
|
||||
);
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
export function isDebugEnabled(): boolean {
|
||||
return window.localStorage.getItem('debug') === 'true';
|
||||
}
|
||||
|
||||
export function debug(...args: any[]): void {
|
||||
if (!isDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const argument of args) {
|
||||
console.debug(argument);
|
||||
}
|
||||
}
|
|
@ -1,49 +1,101 @@
|
|||
[
|
||||
{
|
||||
"regex": "amazon\\.com$",
|
||||
"icon": "7digital.png",
|
||||
"regex": "7digital\\.com$",
|
||||
"text": "7digital"
|
||||
},
|
||||
{
|
||||
"icon": "amazon.png",
|
||||
"regex": "amazon\\.(co\\.jp|co\\.uk|com|de|fr|nl)$",
|
||||
"text": "Amazon"
|
||||
},
|
||||
{
|
||||
"icon": "apple-music.png",
|
||||
"regex": "(itunes|music)\\.apple\\.com$",
|
||||
"text": "Apple Music"
|
||||
},
|
||||
{
|
||||
"icon": "bandcamp.png",
|
||||
"regex": "bandcamp\\.com$",
|
||||
"text": "Bandcamp"
|
||||
},
|
||||
{
|
||||
"icon": "beatport.png",
|
||||
"regex": "beatport\\.com$",
|
||||
"text": "Beatport"
|
||||
},
|
||||
{
|
||||
"icon": "deezer.png",
|
||||
"regex": "deezer\\.com$",
|
||||
"text": "Deezer"
|
||||
},
|
||||
{
|
||||
"icon": "discogs.png",
|
||||
"regex": "discogs\\.com$",
|
||||
"text": "Discogs"
|
||||
},
|
||||
{
|
||||
"icon": "juno-download.png",
|
||||
"regex": "junodownload\\.com$",
|
||||
"text": "Juno Download"
|
||||
},
|
||||
{
|
||||
"icon": "liquicity.png",
|
||||
"regex": "store\\.liquicity\\.com$",
|
||||
"text": "Liquicity Store"
|
||||
},
|
||||
{
|
||||
"icon": "monstercat-music.jpg",
|
||||
"regex": "music\\.monstercat\\.com$",
|
||||
"text": "Monstercat Music"
|
||||
},
|
||||
{
|
||||
"icon": "mora.png",
|
||||
"regex": "mora\\.jp$",
|
||||
"text": "Mora"
|
||||
},
|
||||
{
|
||||
"icon": "napster.png",
|
||||
"regex": "napster\\.com$",
|
||||
"text": "Napster"
|
||||
},
|
||||
{
|
||||
"icon": "ototoy.png",
|
||||
"regex": "ototoy\\.jp$",
|
||||
"text": "Ototoy"
|
||||
},
|
||||
{
|
||||
"icon": "qobuz.png",
|
||||
"regex": "qobuz\\.com$",
|
||||
"text": "Qobuz"
|
||||
},
|
||||
{
|
||||
"icon": "rateyourmusic.png",
|
||||
"regex": "rateyourmusic\\.com$",
|
||||
"text": "Rate Your Music"
|
||||
},
|
||||
{
|
||||
"icon": "soundcloud.png",
|
||||
"regex": "soundcloud\\.com$",
|
||||
"text": "SoundCloud"
|
||||
},
|
||||
{
|
||||
"icon": "spotify.png",
|
||||
"regex": "spotify\\.com$",
|
||||
"text": "Spotify"
|
||||
},
|
||||
{
|
||||
"icon": "tidal.png",
|
||||
"regex": "tidal\\.com$",
|
||||
"text": "Tidal"
|
||||
},
|
||||
{
|
||||
"icon": "youtube-music.png",
|
||||
"regex": "music\\.youtube\\.com$",
|
||||
"text": "YouTube Music"
|
||||
},
|
||||
{
|
||||
"icon": "youtube.png",
|
||||
"__comment": "Make sure we don't match the YouTube Music sub-domain",
|
||||
"regex": "^(www\\.)?(youtu\\.be|youtube\\.com)$",
|
||||
"text": "YouTube"
|
||||
|
|
|
@ -8,16 +8,20 @@
|
|||
import knownLinks from './known-links.json';
|
||||
|
||||
type KnownLink = {
|
||||
icon: string | undefined;
|
||||
regex: RegExp;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const known: KnownLink[] = knownLinks.map((data: Record<string, unknown>) => ({
|
||||
icon: data.icon as string | undefined,
|
||||
regex: new RegExp(data.regex as string),
|
||||
text: data.text as string,
|
||||
}));
|
||||
|
||||
export default class RelationLink {
|
||||
public readonly icon: string | undefined;
|
||||
public readonly isKnown: boolean;
|
||||
public readonly link: URL;
|
||||
public readonly original: string;
|
||||
public readonly text: string;
|
||||
|
@ -27,6 +31,8 @@ export default class RelationLink {
|
|||
this.link = new URL(relationUrl);
|
||||
|
||||
const knownLink = known.find(({regex}) => regex.test(this.link.host));
|
||||
this.icon = knownLink?.icon;
|
||||
this.isKnown = knownLink !== undefined;
|
||||
this.text = knownLink?.text ?? this.link.host;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import {debug} from './debug.js';
|
||||
import RelationLink from './relation-link.js';
|
||||
|
||||
type ApiReleaseData = {
|
||||
|
@ -8,8 +9,11 @@ type ApiReleaseData = {
|
|||
'cover-art-archive': {
|
||||
front: boolean;
|
||||
};
|
||||
date: string | undefined;
|
||||
id: string;
|
||||
relations: Array<{
|
||||
ended: boolean;
|
||||
type: string;
|
||||
url: {
|
||||
resource: string;
|
||||
};
|
||||
|
@ -20,6 +24,7 @@ type ApiReleaseData = {
|
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface IRelease {
|
||||
artist: string;
|
||||
date: string | undefined;
|
||||
image: string | undefined;
|
||||
links: RelationLink[];
|
||||
title: string;
|
||||
|
@ -41,6 +46,7 @@ export default class Release {
|
|||
}
|
||||
|
||||
const data = (await apiResponse.json()) as ApiReleaseData;
|
||||
debug(data);
|
||||
|
||||
const artist = data['artist-credit']
|
||||
.map(({name, joinphrase}) => `${name}${joinphrase}`)
|
||||
|
@ -50,12 +56,30 @@ export default class Release {
|
|||
? `https://coverartarchive.org/release/${mbid}/front-500`
|
||||
: undefined;
|
||||
|
||||
const links = data.relations
|
||||
.map(({url}) => new RelationLink(url.resource))
|
||||
.sort((a, b) => a.text.localeCompare(b.text));
|
||||
const relations = new Set(
|
||||
data.relations
|
||||
// Remove discography entries and links that have been marked as no
|
||||
// longer working.
|
||||
.filter(
|
||||
(relation) =>
|
||||
relation.type !== 'discography entry' && !relation.ended,
|
||||
)
|
||||
.map((relation) => relation.url.resource),
|
||||
);
|
||||
const links = Array.from(relations)
|
||||
.map((url) => new RelationLink(url))
|
||||
.sort((a, b) =>
|
||||
// Sort links that aren't known to us at the bottom.
|
||||
a.isKnown === b.isKnown
|
||||
? a.text.localeCompare(b.text)
|
||||
: b.isKnown
|
||||
? 1 // This return 1 or -1 is because .sort() expects a number.
|
||||
: -1,
|
||||
);
|
||||
|
||||
return new Release({
|
||||
artist,
|
||||
date: data.date,
|
||||
image,
|
||||
links,
|
||||
title: data.title,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {debug} from './debug.js';
|
||||
|
||||
type ApiSearchData = {
|
||||
releases: Array<{
|
||||
'artist-credit': Array<{
|
||||
|
@ -17,11 +19,18 @@ export type SearchResult = {
|
|||
title: string;
|
||||
};
|
||||
|
||||
export const searchLimit = 25;
|
||||
|
||||
export default async function searchReleases(
|
||||
query: string,
|
||||
offset?: number,
|
||||
): Promise<SearchResult[]> {
|
||||
query = encodeURIComponent(query);
|
||||
const url = `https://musicbrainz.org/ws/2/release?query=${query}`;
|
||||
let url = `https://musicbrainz.org/ws/2/release?query=${query}&limit=${searchLimit}`;
|
||||
if (offset !== undefined) {
|
||||
url += `&offset=${offset}`;
|
||||
}
|
||||
|
||||
const response = await window.fetch(url, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
|
@ -34,6 +43,8 @@ export default async function searchReleases(
|
|||
}
|
||||
|
||||
const data = (await response.json()) as ApiSearchData;
|
||||
debug(data);
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const release of data.releases) {
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import {createContext} from 'preact';
|
||||
|
||||
type Theme = {
|
||||
cssClass: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const defaultTheme: Theme = {
|
||||
cssClass: 'love-dark',
|
||||
name: 'Love Dark',
|
||||
};
|
||||
|
||||
export const themes: Theme[] = [
|
||||
defaultTheme,
|
||||
{
|
||||
cssClass: 'love-light',
|
||||
name: 'Love Light',
|
||||
},
|
||||
{
|
||||
cssClass: 'solarized-dark',
|
||||
name: 'Solarized Dark',
|
||||
},
|
||||
{
|
||||
cssClass: 'solarized-light',
|
||||
name: 'Solarized Light',
|
||||
},
|
||||
{
|
||||
cssClass: 'dracula',
|
||||
name: 'Dracula',
|
||||
},
|
||||
{
|
||||
cssClass: 'monokai',
|
||||
name: 'Monokai',
|
||||
},
|
||||
{
|
||||
cssClass: 'high-contrast-black',
|
||||
name: 'High Contrast Black',
|
||||
},
|
||||
{
|
||||
cssClass: 'high-contrast-white',
|
||||
name: 'High Contrast White',
|
||||
},
|
||||
{
|
||||
cssClass: 'gruvbox-dark',
|
||||
name: 'Gruvbox Dark',
|
||||
},
|
||||
{
|
||||
cssClass: 'gruvbox-light',
|
||||
name: 'Gruvbox Light',
|
||||
},
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export const themeContext = createContext<Theme>(defaultTheme);
|
||||
|
||||
export function getThemeByCssClass(cssClass: string): Theme {
|
||||
return themes.find((theme) => theme.cssClass === cssClass) ?? defaultTheme;
|
||||
}
|
||||
|
||||
export function setTheme(theme: Theme): void {
|
||||
document.body.classList.value = theme.cssClass;
|
||||
window.localStorage.setItem('theme', theme.cssClass);
|
||||
}
|
|
@ -13,7 +13,7 @@ function gitRevParse(): string {
|
|||
const revParse = childProcess.spawnSync(
|
||||
'git',
|
||||
['rev-parse', '--short', '--verify', 'main'],
|
||||
{encoding: 'utf-8'},
|
||||
{encoding: 'utf8'},
|
||||
);
|
||||
|
||||
if (revParse.error) {
|
||||
|
@ -33,7 +33,7 @@ export default defineConfig({
|
|||
define: {
|
||||
hrefPlusVersion: JSON.stringify(hrefPlusVersion),
|
||||
hrefPlusCommitHash: gitRevParse(),
|
||||
hrefPlusUserAgent: `"href-plus/${hrefPlusVersion} (https://github.com/Bauke/href-plus)"`,
|
||||
hrefPlusUserAgent: `"href-plus/${hrefPlusVersion} (https://git.bauke.xyz/Bauke/href-plus)"`,
|
||||
},
|
||||
publicDir: path.join(sourceDir, 'assets'),
|
||||
root: sourceDir,
|
||||
|
|