Big commit initial code.
This commit is contained in:
parent
6db6345faf
commit
0da2dba96a
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "blink",
|
||||
"version": "0.1.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Bauke <me@bauke.xyz>",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"test": "xo && stylelint 'source/**/*.scss' && tsc",
|
||||
"deploy": "vite build --emptyOutDir && pnpm deploy:netlify",
|
||||
"deploy:netlify": "netlify deploy --prod -d 'public' -s 'blink.bauke.xyz'"
|
||||
},
|
||||
"dependencies": {
|
||||
"htm": "^3.1.0",
|
||||
"modern-normalize": "^1.1.0",
|
||||
"preact": "^10.6.4",
|
||||
"preact-router": "^3.2.1"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss"
|
||||
],
|
||||
"rules": {
|
||||
"string-quotes": "single"
|
||||
}
|
||||
},
|
||||
"xo": {
|
||||
"prettier": true,
|
||||
"space": true
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,5 @@
|
|||
# Netlify redirects and rewrites configuration
|
||||
# https://docs.netlify.com/routing/redirects/
|
||||
|
||||
# Redirect any non-existing files to /
|
||||
/* / 200
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Blink</title>
|
||||
<link rel="stylesheet" href="./scss/modern-normalize.scss">
|
||||
<link rel="stylesheet" href="./scss/style.scss">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
This site doesn't work without JavaScript, sorry. :(
|
||||
</noscript>
|
||||
|
||||
<script type="module" src="./ts/single-page-application.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,19 @@
|
|||
html {
|
||||
font-size: 62.5%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background-1);
|
||||
color: var(--foreground-1);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--foreground-1);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--foreground-1);
|
||||
color: var(--background-1);
|
||||
text-decoration-color: var(--foreground-1);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.shared-footer {
|
||||
margin: 1rem 0;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@use '../../node_modules/modern-normalize/modern-normalize.css';
|
|
@ -0,0 +1,9 @@
|
|||
.home-page {
|
||||
padding: 1rem;
|
||||
|
||||
h1::after {
|
||||
content: 'WIP';
|
||||
font-size: 1rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.not-found-page {
|
||||
padding: 1rem;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
.release {
|
||||
background-color: var(--background-2);
|
||||
box-shadow: 0 0 1rem #000;
|
||||
margin: 2rem auto;
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.release-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.release-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.release-links {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.no-links {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.release-link {
|
||||
a {
|
||||
background-color: var(--background-1);
|
||||
border-radius: var(--border-radius);
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--foreground-1);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
@use 'reset';
|
||||
@use 'common';
|
||||
|
||||
// Theme definitions
|
||||
@use 'themes/love';
|
||||
|
||||
// Component styling
|
||||
@use 'components/shared-footer';
|
||||
|
||||
// Page styling
|
||||
@use 'pages/home';
|
||||
@use 'pages/not-found';
|
||||
@use 'pages/release';
|
|
@ -0,0 +1,9 @@
|
|||
body,
|
||||
.love-dark {
|
||||
--border-radius: 8px;
|
||||
--foreground-1: #f2efff;
|
||||
--foreground-2: #e6deff;
|
||||
--background-1: #1f1731;
|
||||
--background-2: #2a2041;
|
||||
--accent-1: #d2b83a;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import {html, Component} from 'htm/preact';
|
||||
|
||||
type Props = {
|
||||
extra?: Record<string, string>;
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export default class ExternalAnchor extends Component<Props> {
|
||||
render() {
|
||||
const {extra, text, url} = this.props;
|
||||
|
||||
return html`
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer" ...${extra}>
|
||||
${text}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import ExternalAnchor from './external-anchor.js';
|
||||
|
||||
export default class SharedFooter extends Component {
|
||||
render() {
|
||||
return html`
|
||||
<footer class="shared-footer">
|
||||
<${ExternalAnchor} text="GitHub" url="https://github.com/Bauke/blink" />
|
||||
<span> v${window.blinkVersion} (${window.blinkCommitHash})</span>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import SharedFooter from '../components/shared-footer.js';
|
||||
|
||||
export default class HomePage extends Component {
|
||||
render() {
|
||||
document.title = 'Blink';
|
||||
|
||||
return html`
|
||||
<div class="home-page">
|
||||
<header>
|
||||
<h1>Blink</h1>
|
||||
</header>
|
||||
|
||||
<${SharedFooter} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import SharedFooter from '../components/shared-footer.js';
|
||||
|
||||
export default class NotFoundPage extends Component {
|
||||
render() {
|
||||
document.title = 'Page Not Found';
|
||||
|
||||
return html`
|
||||
<div class="not-found-page">
|
||||
<main>
|
||||
<h1>Page Not Found</h1>
|
||||
<a href="/">← Home</a>
|
||||
</main>
|
||||
|
||||
<${SharedFooter} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import {Component, html} from 'htm/preact';
|
||||
|
||||
import ExternalAnchor from '../components/external-anchor.js';
|
||||
import Release from '../utilities/release.js';
|
||||
|
||||
type Props = {
|
||||
mbid: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
loading: 'calling-api' | 'invalid-id' | 'unknown-release' | 'finished';
|
||||
release: Release | undefined;
|
||||
};
|
||||
|
||||
export default class ReleasePage extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
props.mbid = encodeURIComponent(props.mbid);
|
||||
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loading: 'calling-api',
|
||||
release: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
validateMbid(mbid: string): boolean {
|
||||
return mbid.length === 36 && mbid.split('-').length === 5;
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const {mbid} = this.props;
|
||||
|
||||
if (!this.validateMbid(mbid)) {
|
||||
this.setState({loading: 'invalid-id'});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setState({
|
||||
loading: 'finished',
|
||||
release: await Release.fromMbid(mbid),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
this.setState({loading: 'unknown-release'});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {mbid} = this.props;
|
||||
const {loading, release} = this.state;
|
||||
|
||||
if (loading === 'calling-api') {
|
||||
return html`Loading MBID: ${mbid}`;
|
||||
}
|
||||
|
||||
if (loading === 'invalid-id') {
|
||||
return html`Invalid MBID: ${mbid}`;
|
||||
}
|
||||
|
||||
if (loading === 'unknown-release') {
|
||||
return html`No release found with MBID: ${mbid}`;
|
||||
}
|
||||
|
||||
if (loading === 'finished' && release !== undefined) {
|
||||
document.title = release.display();
|
||||
|
||||
const image =
|
||||
release.image === undefined
|
||||
? undefined
|
||||
: html`<img class="cover-art" src="${release.image}" />`;
|
||||
|
||||
const urls = release.links.map(
|
||||
(link) =>
|
||||
html`
|
||||
<li class="release-link">
|
||||
<${ExternalAnchor} url="${link.original}" text="${link.text}" />
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
|
||||
if (urls.length === 0) {
|
||||
const editUrl = `https://musicbrainz.org/release/${mbid}/edit`;
|
||||
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>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="release">
|
||||
<header class="release-header">
|
||||
${image}
|
||||
<h1>${release.artist}<br />${release.title}</h1>
|
||||
</header>
|
||||
|
||||
<main class="release-main">
|
||||
<ul class="release-links">
|
||||
${urls}
|
||||
</ul>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
// Uncomment when debugging and using Preact's DevTools WebExtension.
|
||||
// import 'preact/debug';
|
||||
|
||||
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';
|
||||
|
||||
render(
|
||||
html`
|
||||
<${Router}>
|
||||
<${HomePage} path="/" />
|
||||
<${ReleasePage} path="/release/:mbid" />
|
||||
<${NotFoundPage} default />
|
||||
<//>
|
||||
`,
|
||||
document.body,
|
||||
);
|
|
@ -0,0 +1,10 @@
|
|||
// Fixes TypeScript thinking this isn't a correct module.
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// These are created in `vite.config.ts` and defined by Vite at build time.
|
||||
blinkCommitHash: string;
|
||||
blinkVersion: string;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
[
|
||||
{
|
||||
"regex": "amazon\\.com$",
|
||||
"text": "Amazon"
|
||||
},
|
||||
{
|
||||
"regex": "(itunes|music)\\.apple\\.com$",
|
||||
"text": "Apple Music"
|
||||
},
|
||||
{
|
||||
"regex": "bandcamp\\.com$",
|
||||
"text": "Bandcamp"
|
||||
},
|
||||
{
|
||||
"regex": "beatport\\.com$",
|
||||
"text": "Beatport"
|
||||
},
|
||||
{
|
||||
"regex": "deezer\\.com$",
|
||||
"text": "Deezer"
|
||||
},
|
||||
{
|
||||
"regex": "qobuz\\.com$",
|
||||
"text": "Qobuz"
|
||||
},
|
||||
{
|
||||
"regex": "soundcloud\\.com$",
|
||||
"text": "SoundCloud"
|
||||
},
|
||||
{
|
||||
"regex": "spotify\\.com$",
|
||||
"text": "Spotify"
|
||||
},
|
||||
{
|
||||
"regex": "tidal\\.com$",
|
||||
"text": "Tidal"
|
||||
}
|
||||
]
|
|
@ -0,0 +1,32 @@
|
|||
// Because the MusicBrainz API doesn't return a name or label for a link, like
|
||||
// for example {"name": "Bandcamp", "url": "https://bandcamp.com/..."}, we have
|
||||
// to figure out a name for each link. So all the known links are simply saved
|
||||
// in a JSON file where we have a regular expression to test for and a
|
||||
// replacement name to use instead. And whenever a link isn't matched to any we
|
||||
// can just use the host name of the URL like "bandcamp.com".
|
||||
|
||||
import knownLinks from './known-links.json';
|
||||
|
||||
type KnownLink = {
|
||||
regex: RegExp;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const known: KnownLink[] = knownLinks.map((data: Record<string, string>) => ({
|
||||
regex: new RegExp(data.regex),
|
||||
text: data.text,
|
||||
}));
|
||||
|
||||
export default class RelationLink {
|
||||
public readonly link: URL;
|
||||
public readonly original: string;
|
||||
public readonly text: string;
|
||||
|
||||
constructor(relationUrl: string) {
|
||||
this.original = relationUrl;
|
||||
this.link = new URL(relationUrl);
|
||||
|
||||
const knownLink = known.find(({regex}) => regex.test(this.link.host));
|
||||
this.text = knownLink?.text ?? this.link.host;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import RelationLink from './relation-link.js';
|
||||
|
||||
type ApiReleaseData = {
|
||||
'artist-credit': Array<{
|
||||
name: string;
|
||||
joinphrase: string;
|
||||
}>;
|
||||
'cover-art-archive': {
|
||||
front: boolean;
|
||||
};
|
||||
id: string;
|
||||
relations: Array<{
|
||||
url: {
|
||||
resource: string;
|
||||
};
|
||||
}>;
|
||||
title: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface IRelease {
|
||||
artist: string;
|
||||
image: string | undefined;
|
||||
links: RelationLink[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default interface Release extends IRelease {}
|
||||
|
||||
export default class Release {
|
||||
public static async fromMbid(mbid: string): Promise<Release> {
|
||||
const apiResponse = await window.fetch(this.apiUrl(mbid), {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
throw new Error(`No release found with MBID ${mbid}`);
|
||||
}
|
||||
|
||||
const data = (await apiResponse.json()) as ApiReleaseData;
|
||||
|
||||
const artist = data['artist-credit']
|
||||
.map(({name, joinphrase}) => `${name}${joinphrase}`)
|
||||
.join('');
|
||||
|
||||
const image = data['cover-art-archive'].front
|
||||
? `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));
|
||||
|
||||
return new Release({
|
||||
artist,
|
||||
image,
|
||||
links,
|
||||
title: data.title,
|
||||
});
|
||||
}
|
||||
|
||||
private static apiUrl(mbid: string): string {
|
||||
const root = 'https://musicbrainz.org/ws/2';
|
||||
return `${root}/release/${mbid}?inc=artists+url-rels`;
|
||||
}
|
||||
|
||||
constructor(release: IRelease) {
|
||||
Object.assign(this, release, {});
|
||||
}
|
||||
|
||||
public display(): string {
|
||||
return `${this.artist} - ${this.title}`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"target": "es2020"
|
||||
},
|
||||
"include": [
|
||||
"source/**/*.ts",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import childProcess from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import url from 'node:url';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
const currentDir = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
|
||||
const buildDir = path.join(currentDir, 'public');
|
||||
const sourceDir = path.join(currentDir, 'source');
|
||||
|
||||
function gitRevParse(): string {
|
||||
const revParse = childProcess.spawnSync(
|
||||
'git',
|
||||
['rev-parse', '--short', '--verify', 'main'],
|
||||
{encoding: 'utf-8'},
|
||||
);
|
||||
|
||||
if (revParse.error) {
|
||||
throw revParse.error;
|
||||
}
|
||||
|
||||
return JSON.stringify(revParse.stdout.trim());
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: buildDir,
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
blinkVersion: JSON.stringify(process.env.npm_package_version),
|
||||
blinkCommitHash: gitRevParse(),
|
||||
},
|
||||
publicDir: path.join(sourceDir, 'assets'),
|
||||
root: sourceDir,
|
||||
});
|
Loading…
Reference in New Issue