1
Fork 0

Compare commits

...

56 Commits
0.1.0 ... main

Author SHA1 Message Date
Bauke cda4cba1bf
Add Nix flake and direnv files. 2024-01-22 13:08:04 +01:00
Bauke a830007ff2
Update dependencies and fix linting issues. 2024-01-22 13:05:03 +01:00
Bauke 8b5d8c047b
Add netlify-cli as a dependency. 2023-04-22 23:45:05 +02:00
Bauke c9db2e7b30
Remove analytics. 2023-04-22 23:43:04 +02:00
Bauke 44b7fdbcb0
Switch to Umami analytics. 2022-10-15 13:28:05 +02:00
Bauke e2215c8a3c
Add Plausible analytics. 2022-09-29 19:06:30 +02:00
Bauke 0c56e3f368
Update GitHub links to Gitea. 2022-09-29 16:53:00 +02:00
Bauke 4bddf6c9f7
Rewrite readme. 2022-09-29 16:27:47 +02:00
Bauke 37dfcfc1f0
Update package.json info. 2022-09-29 16:27:24 +02:00
Bauke 9cd91dc788
Fix XO issues. 2022-09-29 16:17:19 +02:00
Bauke 537c8c0f3e
Update dependencies. 2022-09-29 16:06:28 +02:00
Bauke 2e828eb087
Add Building and License sections to the readme. 2022-02-12 13:04:12 +01:00
Bauke cb2e5e9f17
Add the release date to releases. 2022-02-11 11:48:16 +01:00
Bauke 7129322105
Update dependencies. 2022-02-10 17:51:09 +01:00
Bauke 2a4f6f2c48
Add debug logs for API calls. 2022-02-09 16:04:34 +01:00
Bauke 9f0f5a46b7
Add a debug setting. 2022-02-08 12:19:07 +01:00
Bauke 1a53885993
Add the Gruvbox theme. 2022-02-07 12:00:57 +01:00
Bauke 8d50aee844
Add Rate Your Music to known links. 2022-02-06 12:52:48 +01:00
Bauke 85469eabf7
Add an explicit search limit. 2022-02-05 14:16:58 +01:00
Bauke 6d121fb7b4
Add styling to the load more button (#16). 2022-02-04 12:57:21 +01:00
Bauke 279f40004f
Add a load more button for search results (#16). 2022-02-03 12:23:10 +01:00
Bauke cc98bd87f9
Update dependencies. 2022-02-02 13:35:42 +01:00
Bauke ebfae2069d
Also include the defaults. 2022-02-01 12:04:59 +01:00
Bauke 7b881c6162
Add theming defaults. 2022-02-01 12:04:17 +01:00
Bauke 61e59cb88c
Make border size themeable. 2022-01-31 12:08:54 +01:00
Bauke 24640cce06
Add borders to various boxes. 2022-01-30 12:02:24 +01:00
Bauke f47b53b928
Add high contrast themes. 2022-01-29 14:21:03 +01:00
Bauke e48d500a0c
Sort the themes by name. 2022-01-28 13:10:05 +01:00
Bauke ac9acff203
Add the Monokai theme. 2022-01-27 11:59:27 +01:00
Bauke fb47a795ec
Add the Dracula theme. 2022-01-26 13:04:03 +01:00
Bauke 57f3b64958
Add the Solarized theme. 2022-01-25 11:43:41 +01:00
Bauke ff4e486455
Add Ototoy to known links. 2022-01-24 12:53:48 +01:00
Bauke 2762395adb
Add Mora to known links. 2022-01-23 13:56:15 +01:00
Bauke af0a0bc71b
Add more Amazon TLDs. 2022-01-22 13:09:32 +01:00
Bauke cdc61576c1
Add UK Amazon TLD. 2022-01-21 14:08:23 +01:00
Bauke 0b2026daf3
Add Napster to known links. 2022-01-20 15:56:53 +01:00
Bauke d9895d7b87
Add robots.txt 2022-01-19 14:45:53 +01:00
Bauke 18be96943e
Give the search bar a max width. 2022-01-18 13:10:29 +01:00
Bauke 421c64cd49
Change the overflow to auto instead of scroll. 2022-01-17 13:38:35 +01:00
Bauke 71331a6ee0
Add links to the home and settings pages in the footer. 2022-01-16 13:30:41 +01:00
Bauke bc02927d8b
Add the settings page with the theme selector (#3). 2022-01-15 20:31:51 +01:00
Bauke 25c45d9d01
Add theme get & set functions. 2022-01-14 20:17:52 +01:00
Bauke 98034ae621
Add the Love Light theme. 2022-01-14 20:11:12 +01:00
Bauke 521de9ec70
Create the context and setup for theming. 2022-01-13 20:44:37 +01:00
Bauke 09656ee76c
Add Juno Download icon. 2022-01-12 14:58:07 +01:00
Bauke a3761eb2bb
Add Juno Download to the known links. 2022-01-12 14:56:02 +01:00
Bauke 319a352d2e
Filter out defunct platforms (#11). 2022-01-11 12:40:30 +01:00
Bauke d4d9a8b0f0
Add an icon to the MusicBrainz link. 2022-01-10 12:30:59 +01:00
Bauke 70746f4079
Add the Liquicity Store to known links. 2022-01-10 00:43:03 +01:00
Bauke 2f5c75a68f
Use the correct path for linking to icons. 2022-01-09 14:04:09 +01:00
Bauke 7e59bfbb6b
Add icons to known links (#6). 2022-01-09 14:01:11 +01:00
Bauke 0b9b9fa162
Add 7digital to the known links. 2022-01-08 19:26:09 +01:00
Bauke aeb3baaca2
Make unknown links sorted at the bottom. 2022-01-07 22:51:31 +01:00
Bauke e3faf966df
Filter out discography entries from the known link candidates. 2022-01-07 22:41:48 +01:00
Bauke 67457393d6
Add Monstercat Music to the known links. 2022-01-07 22:41:24 +01:00
Bauke b0a3b960cc
Filter out duplicate known links (#10). 2022-01-07 22:32:34 +01:00
56 changed files with 9530 additions and 1852 deletions

3
.envrc Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
use flake

108
.gitignore vendored
View File

@ -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/

View File

@ -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.

59
flake.lock Normal file
View File

@ -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
}

13
flake.nix Normal file
View File

@ -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; };
}
);
}

View File

@ -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
}
}

File diff suppressed because it is too large Load Diff

7
shell.nix Normal file
View File

@ -0,0 +1,7 @@
{ pkgs ? import <nixpkgs> { } }:
with pkgs;
mkShell rec {
packages = [ cargo-make nodejs nodePackages.pnpm ];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
source/assets/icons/spotify.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
source/assets/robots.txt Normal file
View File

@ -0,0 +1,3 @@
User-agent: *
Disallow: /release/
Disallow: /settings

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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%;
}

View File

@ -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;
}
}
}

View File

@ -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';

View File

@ -0,0 +1,4 @@
body {
--border-radius: 8px;
--border-size: 2px;
}

View File

@ -0,0 +1,6 @@
.dracula {
--foreground-1: #f8f8f2;
--foreground-2: #f8f8f2;
--background-1: #282a36;
--background-2: #44475a;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
@use 'sass:color';
.monokai {
--foreground-1: #d6d6d6;
--foreground-2: #d6d6d6;
--background-1: #{color.scale(#2e2e2e, $lightness: -25%)};
--background-2: #2e2e2e;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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>

View File

@ -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>
`;
}

View File

@ -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>
`;
}

View File

@ -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">

View File

@ -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>
`;
}
}

View File

@ -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,
);

View File

@ -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);
}
}

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -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);
}

View File

@ -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,