1
Fork 0

Big commit initial code.

This commit is contained in:
Bauke 2021-12-30 23:40:57 +01:00
parent 6db6345faf
commit 0da2dba96a
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
25 changed files with 4116 additions and 0 deletions

40
package.json Normal file
View File

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

3518
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

5
source/assets/_redirects Normal file
View File

@ -0,0 +1,5 @@
# Netlify redirects and rewrites configuration
# https://docs.netlify.com/routing/redirects/
# Redirect any non-existing files to /
/* / 200

21
source/index.html Normal file
View File

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

19
source/scss/_common.scss Normal file
View File

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

4
source/scss/_reset.scss Normal file
View File

@ -0,0 +1,4 @@
h1,
p {
margin: 0;
}

View File

@ -0,0 +1,3 @@
.shared-footer {
margin: 1rem 0;
}

View File

@ -0,0 +1 @@
@use '../../node_modules/modern-normalize/modern-normalize.css';

View File

@ -0,0 +1,9 @@
.home-page {
padding: 1rem;
h1::after {
content: 'WIP';
font-size: 1rem;
vertical-align: top;
}
}

View File

@ -0,0 +1,7 @@
.not-found-page {
padding: 1rem;
h1 {
margin-bottom: 1rem;
}
}

View File

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

13
source/scss/style.scss Normal file
View File

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

View File

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

View File

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

View File

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

19
source/ts/pages/home.ts Normal file
View File

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

View File

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

113
source/ts/pages/release.ts Normal file
View File

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

View File

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

10
source/ts/types.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

15
tsconfig.json Normal file
View File

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

37
vite.config.ts Normal file
View File

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