80 Commits
Author SHA1 Message Date
ntr c99b148e15 sample wip 2022-11-26 18:22:13 +11:00
ntr ffc023f419 Merge branch 'master' into sample-offers 2022-11-26 17:31:14 +11:00
ntr f726c960cd style changes and offer loading routing 2022-11-26 13:11:29 +11:00
ntr 18d5f26326 mobile styles 2022-11-26 11:06:57 +11:00
ntr a0cd49b411 discogs and offers inferred types 2022-11-24 09:35:05 +11:00
ntr f546d76912 comments n stuff 2022-11-23 17:27:38 +11:00
ntr 28759f9e70 completely ditch this bundles intermediate data type 2022-11-10 19:46:53 +11:00
ntr 6d18a86697 search just uses query 2022-11-10 17:36:52 +11:00
ntr 5fab62345b logo 2022-10-31 23:27:03 +11:00
ntr 15a84d6eea offer groups with status 2022-10-29 12:42:01 +11:00
ntr b199ce94c1 group status 2022-10-29 11:57:20 +11:00
ntr f0e366a73d styles 2022-10-24 19:23:06 +11:00
ntr 623d0f3938 bundle offers grouped 2022-10-23 15:22:15 +11:00
ntr 7946259550 add logrocket 2022-10-04 22:59:02 +11:00
ntr 015a2fd66c fix fetching login routes stuff 2022-10-04 22:41:49 +11:00
ntr 77428aa7de various styles 2022-09-20 21:25:48 +10:00
ntr 2761456009 correect nginx conf for redirs 2022-09-19 19:56:26 +10:00
ntr 6b88740595 extra info 2022-09-19 19:55:47 +10:00
ntr 9fad9a69ec nginx conf 2022-09-19 19:46:52 +10:00
ntr 9f0bb35d09 don't upstream client in prod nginx 2022-09-19 19:45:10 +10:00
ntr e792951d24 no client in prod 2022-09-19 19:44:33 +10:00
ntr ef33baacba static 2022-09-19 19:41:47 +10:00
ntr 9b8676809d email subject 2022-09-19 19:37:47 +10:00
ntr e45b2ca9cd styles and login return 2022-09-19 19:36:02 +10:00
ntr 1e368913ab info page 2022-09-19 19:34:04 +10:00
ntr f6f03730e3 login check effect 2022-09-18 21:04:01 +10:00
ntr 3bd8691ebf sync 2022-09-18 20:32:42 +10:00
ntr 470270954f styles n stuff 2022-09-18 20:26:57 +10:00
ntr 6b8ebe3f35 rn home offers 2022-09-16 21:48:26 +10:00
ntr acf7e54be9 remove unused api methods 2022-09-16 21:48:14 +10:00
ntr 73248f7d9f big websocket change 2022-09-16 19:21:11 +10:00
ntr 0e3c272adc fix user rerender race condition 2022-09-15 23:47:20 +10:00
ntr d2ec08159d ws url connect 2022-09-15 23:02:33 +10:00
ntr 12c3da9f5e wants list redux and removal 2022-09-15 23:00:35 +10:00
ntr 70a67b0c53 startup sync 2022-09-15 22:20:48 +10:00
ntr 644398319d prod nginx changes and remove want on offer complete 2022-09-15 22:14:54 +10:00
ntr 2c66c47163 rpc 2022-09-11 21:16:33 +10:00
ntr 111f690482 remove preact prerender from build 2022-09-10 19:51:49 +10:00
ntr b0572cb63d based redux 2022-09-10 18:54:01 +10:00
ntr 5f853696ab offers changes and select status 2022-09-10 17:47:18 +10:00
ntr bbc3fe6b48 singularise listing fetching 2022-09-05 21:21:34 +10:00
ntr f2294def05 this is podracing 2022-09-04 20:36:42 +10:00
ntr 65d37eeb61 ws 2022-09-04 18:47:51 +10:00
ntr 0bf75d7faa nearly 2022-09-03 15:03:16 +10:00
ntr cf01670ee6 unistore wip 2022-09-03 14:26:41 +10:00
ntr a43d4f32e4 simplify and automate offer creation 2022-09-02 00:06:51 +10:00
ntr 7ea2c37116 logout and info 2022-08-30 23:02:34 +10:00
ntr 4041d9827d autofocus 2022-08-30 20:34:57 +10:00
ntr 94dfe7c082 use correct nginx conf 2022-08-30 19:45:01 +10:00
ntr 20a587600e add 433 to prod nginx 2022-08-30 19:42:32 +10:00
ntr 106921a0da binary targets 2022-08-30 19:38:08 +10:00
ntr 2804477601 compose and nginx 2022-08-30 19:25:36 +10:00
ntr 44c83d3b33 moar logs 2022-08-30 18:39:22 +10:00
ntr 70cb07ee27 size plugin thing 2022-08-25 23:22:37 +10:00
ntr 9d21ee63d3 maybe i don't need tsconfig 2022-08-25 23:21:59 +10:00
ntr 82c905650f can't get build to ignore ts errors 2022-08-25 23:05:20 +10:00
ntr 047f606e83 acp stuff 2022-08-23 23:35:25 +10:00
ntr deb9eb3fc2 offers wip 2022-08-23 00:02:50 +10:00
ntr 453c2da045 offers page 2022-08-20 22:36:17 +10:00
ntr 323c7877b1 yeh boi 2022-08-18 00:34:46 +10:00
ntr 30bf7426e4 want events 2022-08-17 23:22:48 +10:00
ntr f5e75f4fe6 wants wip" 2022-08-16 10:09:36 +10:00
ntr c99f6fd2b6 error msg is back 2022-08-14 23:17:58 +10:00
ntr 12f00eb639 api response 2022-08-14 23:04:56 +10:00
ntr 7121b2f4cd interceptor error types needed 2022-08-14 22:56:46 +10:00
ntr b0bca74ee2 stylez 2022-08-14 22:17:42 +10:00
ntr 9a42c290ae css and some nav 2022-08-10 23:47:33 +10:00
ntr a8e5e5d7cd more auth stuff 2022-08-08 22:58:19 +10:00
ntr 65e603f222 cookies working for some raeson now 2022-08-08 21:36:46 +10:00
ntr 413aeedadf auth init 2022-08-08 19:51:17 +10:00
ntr faa2e70e08 search 2022-08-08 11:22:05 +10:00
ntr bd32044427 move to koa and add user pages 2022-08-07 14:27:13 +10:00
ntr b4114a2440 non discogs users 2022-08-06 21:49:40 +10:00
ntr 66dcdaeb70 rename tables 2022-08-06 18:18:24 +10:00
ntr b622d2cdbd ships 2022-08-04 21:47:02 +10:00
ntr 413c1f4840 nice bundles markup 2022-08-03 00:05:06 +10:00
ntr 02aadcf2db unistore sucks i think 2022-08-01 18:29:23 +10:00
ntr baeaead650 client init 2022-08-01 00:05:55 +10:00
ntr 8700b15f39 bundle it up 2022-07-28 20:15:27 +10:00
ntr adcc19564f prisma pretty noice 2022-07-28 00:18:11 +10:00
78 changed files with 48511 additions and 3804 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# TODO
listing ids overflowing
skip unofficial release
status stepper
queue up wantlist adds to gracefully handle rate limiting
push offers
cover off admin routes and commands
listen / notify
on offers
notify admins
View File
File renamed without changes.
+8
View File
@@ -0,0 +1,8 @@
{
"extension": ["ts"],
"spec": "src/**/*.test.ts",
"require": "ts-node/register",
"watch-files": "**/*.ts",
"watch-ignore": ["db"]
}
View File
File renamed without changes.
+16
View File
@@ -0,0 +1,16 @@
// global type aliases and whatnot
type UUID = string;
type Email = string;
type DiscogsName = string;
type DiscogsListing = number;
type DiscogsRelease = number;
type DiscogsSeller = number;
type BundleCandidate = {
seller: DiscogsSeller,
count: number,
listings: DiscogsListing[],
}
+37
View File
@@ -0,0 +1,37 @@
import createServices from './src/services';
import createLog from './src/log';
import { Events } from './src/events';
async function start() {
const log = createLog();
// process.on('uncaughtException', (err, origin) => {
// log.error({ err, origin }, 'uncaughtException');
// process.exit(1);
// });
// process.on('unhandledRejection', (reason, promise) => {
// log.error({ reason, promise }, 'unhandledRejection');
// process.exit(1);
// });
process.on('SIGTERM', () => {
log.debug('SIGTERM received, exiting');
process.exit(0);
});
const {
prisma,
pg,
discogs,
api,
events,
} = createServices(log);
// events.emit(Events.StartupSync);
// events.emit(Events.ListingsUpdated);
api.listen();
}
start();
+6495
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "mates-crates",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "mocha",
"test:watch": "mocha --watch",
"migrate": "knex migrate:latest",
"migrate:make": "prisma migrate dev --name",
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.36",
"@types/koa": "^2.13.5",
"@types/koa-websocket": "^5.0.7",
"@types/lodash": "^4.14.182",
"@types/mocha": "^9.1.1",
"axios": "^0.27.2",
"mocha": "^10.0.0",
"node-dev": "^7.4.3",
"prisma": "^4.5.0",
"ts-node-dev": "^2.0.0",
"typescript": "^4.7.4"
},
"dependencies": {
"@koa/cors": "^3.3.0",
"@koa/router": "^12.0.0",
"@prisma/client": "^4.5.0",
"bcrypt": "^5.0.1",
"bluebird": "^3.7.2",
"bunyan": "^1.8.15",
"cheerio": "^1.0.0-rc.12",
"koa": "^2.13.4",
"koa-body": "^5.0.0",
"koa-passport": "^5.0.0",
"koa-session": "^6.2.0",
"koa-websocket": "^7.0.0",
"koa-ws": "^1.0.1",
"lodash": "^4.17.21",
"passport-local": "^1.0.0"
},
"overrides": {
"koa-passport": {
"passport": "^0.5"
}
}
}
+87
View File
@@ -0,0 +1,87 @@
generator client {
provider = "prisma-client-js"
// include for node docker image
binaryTargets = ["native", "debian-openssl-1.1.x"]
previewFeatures = ["interactiveTransactions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Listing {
id Int @id
seller Int
data Json
wantId String @db.Uuid
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
offers Offer[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([seller])
@@map("listings")
}
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
wants Want[]
offers Offer[]
@@map("users")
}
model Want {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
release Int
data Json
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
offers Offer[]
@@unique([userId, release])
@@index([release])
@@map("wants")
}
model Offer {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
status String
condition String @default("")
item Decimal @db.Money
shipping Decimal @db.Money
total Decimal @db.Money
listingId Int
listing Listing @relation(fields: [listingId], references: [id])
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wantId String @db.Uuid
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
// only one offer per want for a user
// @@unique([userId, wantId])
@@unique([listingId])
@@map("offers")
}
+59
View File
@@ -0,0 +1,59 @@
import assert from 'assert/strict';
import axios from 'axios';
import createServices from './services';
import createLog from './log';
const log = createLog();
log.level('debug');
const services = createServices(log);
const api = axios.create({
baseURL: 'http://localhost:41337',
withCredentials: true
});
let server;
before(function setup() {
server = services.api.listen();
});
after(function teardown(done) {
server.close(done)
});
describe('api', function apiTests() {
describe.skip('auth', function authTests() {
it('listens', async function listenTest() {
const res = await api.get('/health');
assert.equal(res.status, 200);
});
it('register', async function register() {
const body = {
email: `test+${Date.now()}@user.com`,
password: 'woaaah theres no limit',
};
const { data } = await api.post('/auth/register', body);
assert.equal(data.email, body.email);
});
it('login', async function login() {
const body = {
email: `test+${Date.now()}@user.com`,
password: 'woaaah theres no limit',
};
await api.post('/auth/register', body);
const res = await api.post('/auth/login', body);
assert(res.headers['set-cookie']);
})
});
describe('offers', function offerTests() {
it('create offers', async function createOffersTest() {
const { data: bundles } = await api.get('/bundles');
const listingIds = bundles[0].items.map(i => i.id);
const { data } = await api.post('/offers', { listingIds, shipping: 50.0, baseShipping: 15.0 });
});
});
});
+153
View File
@@ -0,0 +1,153 @@
import http from 'http';
import koa from 'koa';
import koaWebsocket from 'koa-websocket';
import bodyParser from 'koa-body';
import session from 'koa-session';
import koaRouter from '@koa/router'
import cors from '@koa/cors';
import { groupBy, sortBy } from 'lodash';
import Bluebird from 'bluebird';
import type Log from 'bunyan';
import type { Listing, User, Want, Prisma } from '@prisma/client';
import { Pg } from './pg';
import { Discogs } from './discogs';
import { Offers } from './offers';
import routerAuth from './routers/router.auth';
import { Events } from './events';
import createRpc, { RpcMessageType } from './rpc';
export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Offers, events) {
const PORT = 41337;
const WS_PORT = 40055;
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
const app = koaWebsocket(new koa());
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
log.error(err);
ctx.status = err.statusCode || err.status || 500;
ctx.body = {
error: err.message
};
}
});
const router = koaRouter();
function listen() {
const server = app.listen(PORT);
log.info({ PORT }, 'listening');
}
app.use(cors({ credentials: true }));
app.use(bodyParser())
app.keys = [SECRET];
app.use(session(app));
const { authRouter, passport } = routerAuth(log, pg);
app.use(passport.initialize());
app.use(passport.session())
app.use(authRouter.routes());
router.get('/health', async function health(ctx) {
ctx.status = 200;
});
router.get('/user/offers', async function userOffersGet(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user;
const offers = await pg.userOffers(user);
ctx.response.body = offers;
});
router.post('/want', async function wantPost(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { releaseId } = ctx.request.body;
const user = await pg.userFindId(ctx.state.user.id);
const release = await discogs.release(releaseId);
const want = await pg.wantCreate(release, user);
events.emit(Events.WantsAdded, [want]);
ctx.response.body = [want];
});
router.post('/want/discogs', async function wantListPost(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { username } = ctx.request.body;
const user = await pg.userFindId(ctx.state.user.id);
const wants = await discogs.wantListSync(username, user);
events.emit(Events.WantsAdded, [wants]);
ctx.response.body = wants;
});
router.get('/want/all', async function wantGet(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user;
const wants = await pg.wantList(user);
ctx.response.body = wants;
});
router.post('/want/search', async function wantSearch(ctx, next) {
const { query } = ctx.request.body;
const results = await discogs.search(query);
ctx.response.body = results;
});
router.post('/auth/logout', async (ctx, next) => {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
ctx.logout();
ctx.status = 200;
ctx.body = { success: true };
});
app.use(router.routes());
app.ws.use(passport.initialize());
app.ws.use(passport.session());
app.ws.use(async (ctx, next) => {
try {
log.info('new ws connection', { user: ctx.state.user });
// log.child...
const ws = ctx.websocket;
const rpc = createRpc(log, pg, ws, discogs, offers, ctx.state.user);
if (ctx.state.user) events.emit(Events.Connection, { ws, id: ctx.state.user.id });
ws.on('message', async function incoming(message) {
try {
const parsed = JSON.parse(message.toString());
const response = await rpc.receive(parsed);
rpc.send(response);
} catch (e) {
log.warn(e);
log.warn(message.toString());
rpc.send([RpcMessageType.Error, e.message || e.msg]);
}
return true;
});
ws.on('close', function onClose() {
log.info('ws disconnected', { user: ctx.state.user });
});
await rpc.onConnection();
} catch (e) {
log.error(e);
}
});
return {
app,
listen,
};
}
+116
View File
@@ -0,0 +1,116 @@
import fs from 'fs/promises';
import axios from 'axios';
import cheerio from 'cheerio';
import Bluebird from 'bluebird';
import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing } from '@prisma/client';
import { logAxiosError, wantDetails } from './utils';
import { Pg } from './pg';
// margh
// vanderbabe
// jmcdermid1
export default function createDiscogs(log: Log, pg: Pg) {
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
const apiBase = 'https://api.discogs.com'
const discogsBase = 'https://www.discogs.com'
const api = axios.create({
headers: {
'Authorization': TOKEN,
// discogs only returns the first page without a desktop u/a
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0',
},
});
async function handleApiError(e) {
logAxiosError(e);
if (e.response.status === 429) {
throw new Error('rate limited');
}
return null;
}
async function wantListSync(username: string, user: User): Promise<Want[]> {
const { data: { wants, pagination }} = await api.get(`${apiBase}/users/${username}/wants`);
const releases = await Promise.all(wants.map(want => release(want.id)));
log.info({ wants: wants.length, user: username, userId: user.id }, 'fetched discogs wantlist');
// TODO filter releases to be vinyl format only
// convert want object to actual release and ignore duplicate errors
const results = await Promise.allSettled(releases.map(release => pg.wantCreate(release, user)));
// @ts-ignore
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
log.info(additions);
return additions;
}
async function release(id: DiscogsRelease): Promise<any> {
const { data } = await api.get(`${apiBase}/releases/${id}`);
return data;
}
async function search(query: string) {
const url = new URL(`${apiBase}/database/search`);
url.searchParams.append('query', query);
url.searchParams.append('type', 'release');
url.searchParams.append('format', 'vinyl');
const { data: { results } } = await api.get(url.href);
return results;
}
async function inventory(user: string) {
const res = await api.get(`${apiBase}/users/${user}/inventory`);
return res.data.listings;
}
async function listings(want: Want) {
const res = await api.get(`https://www.discogs.com/sell/release/${want.release}?limit=250`);
const $ = cheerio.load(res.data);
const ids = $('.item_description_title[href]')
.map(function getListingId() {
const id = Number.parseInt(this.attribs.href.replace('/sell/item/', ''), 10);
return id;
})
.get();
const unlisted = await pg.listingRmUnlisted(want, ids);
if (unlisted) log.info({ unlisted: unlisted, ...wantDetails(want) }, 'missing listings removed');
// preferentially take listings from sellers if we hit req limits
return Bluebird.mapSeries(ids, async id => listing(id, want));
}
async function listing(id, want) {
const listing = await pg.listingFind(id);
if (listing) return listing;
try {
log.debug('fetching listing', id);
const res = await api.get(`${apiBase}/marketplace/listings/${id}`);
log.debug(res.data);
return pg.listingUpsert(res.data, want);
} catch (e) {
return handleApiError(e);
}
}
return {
wantListSync,
inventory,
listings,
listing,
release,
search,
};
}
export type Discogs = ReturnType<typeof createDiscogs>;
+73
View File
@@ -0,0 +1,73 @@
import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises';
import type Log from 'bunyan';
import type { Want } from '@prisma/client';
import { Pg } from './pg';
import { wantDetails } from './utils';
import routerAuth from './routers/router.auth';
export enum Events {
WantsAdded = 'WantsAdded',
StartupSync = 'StartupSync',
ListingsUpdated = 'ListingsUpdated',
Connection = 'Connection',
}
export default function createEvents(log: Log, pg: Pg, discogs, offers) {
const events = new EventEmitter({ captureRejections: true });
const connections = new Map();
async function populateListings(want: Want) {
try {
const listings = await discogs.listings(want);
return listings;
} catch (e) {
if (e.message === 'rate limited') {
log.warn('rate limited, trying again in 60s');
await setTimeout(60 * 1000);
return populateListings(want);
}
throw e;
}
}
events.on('error', function eventError(e) {
log.error('event error', e);
});
async function updateWantListings(wants: Want[]) {
for (const want of wants) {
const listings = await populateListings(want);
log.info({ listings: listings.length, ...wantDetails(want) }, 'listings added');
events.emit(Events.ListingsUpdated);
}
log.info({ wants: wants.length }, 'finished syncing');
}
events.on(Events.WantsAdded, async function wantsAdded(wants: Want[]) {
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population');
await updateWantListings(wants);
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings');
});
events.on(Events.StartupSync, async function startupSync() {
const wants = await pg.wantsToSync();
log.info({ event: Events.StartupSync, wants: wants.length });
await updateWantListings(wants);
events.emit(Events.ListingsUpdated);
});
events.on(Events.ListingsUpdated, async function listingsUpdated() {
const updatedOffers = await offers.generate()
log.debug({ offers: updatedOffers.length }, 'offers up to date');
});
events.on(Events.Connection, async function onConnection({ ws, id }) {
connections.set(id, ws);
})
return events;
}
+10
View File
@@ -0,0 +1,10 @@
import bunyan from 'bunyan';
export default function createLog() {
const log = bunyan.createLogger({
name: 'mcrates',
stream: process.stdout
});
return log;
}
+150
View File
@@ -0,0 +1,150 @@
import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing, Offer, } from '@prisma/client';
import assert from 'assert/strict';
import groupBy from 'lodash/groupBy';
import sortBy from 'lodash/sortBy';
import flatMap from 'lodash/flatMap';
import { Context } from 'koa';
import { logAxiosError, wantDetails } from './utils';
import { Pg } from './pg';
export enum Status {
Complete = 'Complete',
Confirmed = 'Confirmed',
Accepted = 'Accepted',
Pending = 'Pending',
Declined = 'Declined',
}
function sortOffers(o) {
return Object.values(Status).indexOf(o.status);
}
export default function createOffers(log: Log, pg: Pg) {
async function generate() {
const listings = flatMap(await pg.interestingListings());
log.debug('generateOffers', { listings: listings.length });
const shipping = 7.5; // yolt
const offers = listings.map(l => ({
userId: l.want.user.id,
listingId: l.id,
wantId: l.want.id,
status: Status.Pending,
condition: l.data.condition,
item: l.data.price.value,
shipping,
total: l.data.price.value + shipping,
}))
// we must sort the offers by price,
// sometimes a seller has multiples of a want
// to deal with this we just ignore duplicates on insert
// and ensure the cheapest one goes in first
const sorted = sortBy(offers, ['item']);
// TODO probably need to take one of each condition of record from a store
// eg. they have a VG+ and a M we should make offers for each
log.info(sorted);
const inserts = await pg.offersUpdate(sorted);
return inserts;
}
async function accept(ctx: Context) {
const { id } = ctx.request.body;
ctx.assert(id, 401, 'no id supplied');
// check the user actually has this offer
const offer = await pg.userOfferFind(ctx.state.user, id);
return pg.offerStatusSet(offer.id, Status.Accepted);
}
async function statusSet(user: User, id: UUID, status: Status) {
log.info({ status, id }, 'setting offer status');
const offer = await pg.offerFindId(id);
const update = await pg.offerStatusSet(offer.id, status);
if (status === Status.Complete) {
log.info({ id, status }, 'completed offer, removing want...');
await pg.wantRm(offer.wantId);
}
return update;
}
// converts offers into groups based on seller
// includes some bits of info about shipping etc
async function acpOffers() {
const offers = await pg.offersAdmin();
const grouped = groupBy(offers, o => o.listing.data.seller.id);
const sellerOffers = sortBy(Object.values(grouped).map(offers => {
const shipping = offers[0]?.listing.data.seller.shipping;
const bundle = {
username: offers[0]?.listing.data.seller.username,
seller: offers[0]?.listing.data.seller.id,
shipsFrom: offers[0]?.listing.data.ships_from,
shipping,
offers: sortBy(offers, sortOffers),
mentionsAustralia: shipping.toLowerCase().includes('australia'),
mentionsWorldwide: shipping.toLowerCase().includes('world'),
}
return bundle;
}), bundle => -bundle.offers.length);
return sellerOffers;
}
// also groups offers by seller for users
// but mostly just munges the structure so the details
// are easily available
async function userOffers(user: User) {
const offers = await pg.userOffers(user);
const groups = Object.values(groupBy(offers, (o) => o.wantId))
.map(g => {
const offers = sortBy(g, sortOffers);
// the offer status is just the most advanced status
// based on sort
const status = offers[0].status;
const details = offers[0].want.data;
return {
details,
offers,
status,
};
});
return groups;
}
async function samples() {
const user = await pg.userFindEmail('ntr@mnml.gg');
const offerGroups = await userOffers(user);
return offerGroups.slice(0, 1);
}
return {
generate,
accept,
statusSet,
acpOffers,
userOffers,
samples,
};
}
export type Offers = ReturnType<typeof createOffers>;
+307
View File
@@ -0,0 +1,307 @@
import bcrypt from 'bcrypt';
import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing, Offer, Prisma } from '@prisma/client';
import * as Bluebird from 'bluebird';
import _ from 'lodash';
import { Status } from './offers';
export interface UserCreateParams {
email: string,
password: string,
}
export default function createPg(log: Log, prisma: PrismaClient) {
async function userFindId(id: UUID) {
return prisma.user.findUnique({ where: { id }});
}
async function userFindEmail(email: Email) {
return prisma.user.findUnique({ where: { email }});
}
async function userWants(user: User) {
return prisma.want.findMany({ where: { userId: user.id }});
}
async function userOffers(user: User) {
return prisma.offer.findMany({
where: {
userId: user.id,
status: { not: Status.Declined }
},
include: {
want: true,
},
orderBy: [
{ total: 'asc' },
{ listingId: 'asc' },
],
});
}
async function offerFindId(id: UUID) {
return prisma.offer.findFirstOrThrow({
where: { id },
include: {
want: true,
}
});
}
async function userOfferFind(user: User, id: UUID) {
return prisma.offer.findFirstOrThrow({
where: {
userId: user.id,
id,
},
include: {
want: true,
}
});
}
async function userCreate(params: UserCreateParams) {
const hashRounds = 10;
const password = await bcrypt.hash(params.password, hashRounds);
const data = {
email: params.email,
password,
};
return prisma.user.create({
data,
});
}
// userUpdate({ email: 'jen@vdk.com', password: 'jen' });
// userUpdate({ email: 'james@mcd', password: 'james' });
async function userUpdate(params: UserCreateParams) {
const hashRounds = 10;
const password = await bcrypt.hash(params.password, hashRounds);
const data = {
email: params.email,
password,
};
return prisma.user.update({
where: { email: params.email },
data,
});
}
async function wantList(user: User) {
return prisma.want.findMany({
where: { userId: user.id },
});
}
async function wantsToSync() {
return prisma.want.findMany({});
}
async function wantRm(id: UUID) {
await prisma.want.delete({
where: { id },
});
}
async function wantCreate(discogsRelease, user) {
const data: { userId: UUID, release: DiscogsListing, data: any } = {
data: discogsRelease,
release: discogsRelease.id,
userId: user.id,
};
return prisma.want.create({
data,
});
}
async function listingRmUnlisted(want: Want, currentListingIds: DiscogsListing[]) {
return prisma.$transaction(async (prisma) => {
log.debug({ currentListingIds });
const protectedOfferStatuses = [Status.Confirmed, Status.Complete];
// can't delete a listing with an open offer
// so delete those offers first
const { count: offersRemoved } = await prisma.offer.deleteMany({
where: {
wantId: want.id,
listingId: {
notIn: currentListingIds,
},
status: {
notIn: protectedOfferStatuses,
},
}
});
if (offersRemoved > 0) log.info({ offersRemoved }, 'open offers removed');
// now we have to filter listingIds with confirmed offers
// and get their listingIds to filter out
const confirmedListingIds = (await prisma.offer.findMany({
where: {
wantId: want.id,
status: {
in: protectedOfferStatuses,
}
},
select: {
listingId: true,
}
})).map(r => r.listingId);
const removalIds = _.without(currentListingIds, ...confirmedListingIds);
// finally we delete listings that aren't related
// to a historical offer
const { count } = await prisma.listing.deleteMany({
where: {
wantId: want.id,
id: {
notIn: currentListingIds,
}
}
});
return count;
});
}
async function listingUpsert(listing, want) {
const values = {
id: listing.id,
wantId: want.id,
seller: listing.seller.id,
data: listing,
};
return prisma.listing.upsert({
where: {
id: listing.id
},
update: values,
create: values,
});
}
async function listingFind(id: DiscogsListing) {
return prisma.listing.findUnique({
where: {
id,
},
include: {
want: {
include: { user: true },
}
}
});
}
async function listingByIds(ids: DiscogsListing[]) {
return prisma.listing.findMany({
where: {
id: { in: ids },
},
include: {
want: {
include: { user: true },
}
}
});
}
async function interestingListings() {
// this basically just needs to return the listingIds to generate offers
// but for ease of abstraction we get the listings themselves
// grouped by seller
const candidates = await prisma.$queryRaw`
select
seller, count(distinct("wantId")), array_agg(id) as listings
from
listings
where
seller not in (3709203)
group by
seller
order by
count desc
limit 5;
` as BundleCandidate[];
return Promise.all(candidates.map(async c => prisma.listing.findMany({
where: {
id: { in: c.listings }
},
include: {
want: {
include: { user: true },
}
},
distinct: ['wantId'],
})));
}
async function offersUpdate(offers) {
return await prisma.$transaction(
offers.map(offer => prisma.offer.upsert({
where: {
listingId: offer.listingId,
},
create: offer,
update: _.omit(offer, ['status', 'id']),
}))
);
}
async function offerStatusSet(id: UUID, status: Status) {
return prisma.offer.update({
where: { id: id },
data: { status },
});
}
// effectively all offers
async function offersAdmin() {
return prisma.offer.findMany({
include: {
listing: true,
user: true,
want: true,
},
});
}
return {
userFindId,
userFindEmail,
userCreate,
userWants,
userOffers,
userOfferFind,
wantCreate,
wantList,
wantsToSync,
wantRm,
listingUpsert,
listingFind,
listingRmUnlisted,
listingByIds,
interestingListings,
offersUpdate,
offerStatusSet,
offersAdmin,
offerFindId,
};
}
export type Pg = ReturnType<typeof createPg>;
+75
View File
@@ -0,0 +1,75 @@
import bcrypt from 'bcrypt';
import { KoaPassport } from 'koa-passport';
import { Strategy as LocalStrategy } from 'passport-local';
import Router from '@koa/router'
import type { Pg } from './../pg';
export default function authRouter(log, pg: Pg) {
const passport = new KoaPassport();
async function verifyPassword(user, password) {
const match = await bcrypt.compare(password, user.password);
return match;
}
passport.use(new LocalStrategy(
{ usernameField: 'email' },
function localStrategy(email, password, done) {
pg.userFindEmail(email)
.then(async user => {
if (!user) return done(null, false);
const passwordMatch = await verifyPassword(user, password);
if (!passwordMatch) return done(null, false);
return done(null, user);
})
.catch(e => done(e, null));
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
pg.userFindId(id)
.then(u => done(null, u))
.catch(e => done(e, null));
})
const authRouter = new Router({ prefix: '/auth' });
authRouter.post('/login', async (ctx, next) => {
await passport.authenticate('local', (err, user, info, status) => {
ctx.assert(user, 401, 'incorrect login');
ctx.login(user);
ctx.status = 200;
ctx.body = {
email: user.email,
};
})(ctx, next);
});
authRouter.get('/login', async (ctx, next) => {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user;
ctx.status = 200;
ctx.body = {
id: user.id,
email: user.email,
};
});
authRouter.post('/register', async (ctx, next) => {
const { discogs, email, password } = ctx.request.body;
// fetch discogs data if they have it
const params = { discogs, email, password, data: {} };
const user = await pg.userCreate(params)
ctx.login(user);
ctx.status = 200;
ctx.body = {
id: user.id,
email: user.email,
};
});
return { authRouter, passport };
}
+210
View File
@@ -0,0 +1,210 @@
import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises';
import assert from 'assert/strict';
import type Log from 'bunyan';
import type { Want, Offer, User } from '@prisma/client';
import type { Pg } from './pg';
import type { Discogs } from './discogs';
import type { Offers } from './offers';
import { Status } from './offers';
import { isAdmin } from './utils';
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
if (RpcMessageType[msgType]) {
return true;
}
return false;
}
// rpc is created per connection from the api
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, user: User | null) {
async function ping(): Promise<PongMessage> {
return [RpcMessageType.Pong, []];
}
async function userOffers(): Promise<OffersMessage> {
return [RpcMessageType.Offers, await offers.userOffers(user)];
}
async function userWants(): Promise<WantsMessage> {
return [RpcMessageType.Wants, await pg.userWants(user)];
}
async function offerStatusSet(params: OfferStatusSetParams): Promise<OffersMessage> {
const { id, status } = params;
assert(id, 'no id supplied');
assert(Status[status], 'unknown status');
const offer = await offers.statusSet(user, id, status);
const userOffers = await offers.userOffers(user);
return [RpcMessageType.Offers, userOffers]
}
async function wantRm(params: WantRmParams): Promise<WantsMessage> {
const { id } = params;
assert(id, 'no id supplied');
await pg.wantRm(id);
const wants = await pg.userWants(user);
return [RpcMessageType.Wants, wants];
}
async function acpOfferStatusSet(params: OfferStatusSetParams): Promise<AcpOffersMessage> {
const { id, status } = params;
assert(isAdmin(user), 'not authorised');
assert(id, 'no id supplied');
assert(Status[status], 'unknown status');
const offer = await offers.statusSet(user, id, status);
return [RpcMessageType.AcpOffers, await offers.acpOffers()]
}
async function acpOffers(): Promise<AcpOffersMessage> {
assert(isAdmin(user), 'not authorised');
return [RpcMessageType.AcpOffers, await offers.acpOffers()];
}
async function receive(message: any): Promise<RpcMessage> {
const [msgType, params] = message;
if (!user) {
log.debug('anon rpc call');
return [RpcMessageType.WhoDis, []];
}
if (msgType !== RpcMessageType.Ping) log.info({ msgType, params }, 'rpc message');
// // this sucks but can't figure out how to convince typescript to agree yet
if (checkMsgType(msgType)) {
switch (msgType) {
case RpcMessageType.Ping:
return ping();
case RpcMessageType.Offers:
return userOffers();
case RpcMessageType.Wants:
return userWants();
case RpcMessageType.OfferStatusSet:
return offerStatusSet(params);
case RpcMessageType.WantRm:
return wantRm(params);
case RpcMessageType.AcpOfferStatusSet:
return acpOfferStatusSet(params);
case RpcMessageType.AcpOffers:
return acpOffers();
default:
throw new Error(`Message type ${msgType} is known but unhandled`);
}
} else {
throw new Error(`Unknown message type ${msgType}`);
}
throw new Error(`Invalid parameters for message type ${msgType}: ${JSON.stringify(message)}`);
}
async function anonConnection() {
const samples = await offers.samples();
const sampleMsg: OfferSamplesMessage = [RpcMessageType.OfferSamples, samples];
send(sampleMsg);
}
async function onConnection() {
if (!user) return anonConnection();
const userMsg: UserMessage = [RpcMessageType.User, user];
send(userMsg);
const wants = await pg.userWants(user);
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
send(wantsMsg);
}
function send(msg: RpcMessage) {
ws.send(JSON.stringify(msg, (key, value) =>
typeof value === 'bigint'
? value.toString()
: value // return everything else unchanged
))
}
return {
receive,
send,
onConnection,
}
}
export enum RpcMessageType {
Ping = 'Ping',
Pong = 'Pong',
// points out user is anon
WhoDis = 'WhoDis',
Bundles = 'Bundles',
Offers = 'Offers',
List = 'List',
Wants = 'Wants',
User = 'User',
Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers',
WantRm = 'WantRm',
}
type ErrorMessage = [RpcMessageType.Error, string];
type PongMessage = [RpcMessageType.Pong, []];
type PingMessage = [RpcMessageType.Ping, []];
type WhoDisMessage = [RpcMessageType.WhoDis, []];
type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
export type RpcMessage =
PingMessage |
PongMessage |
WhoDisMessage |
UserMessage |
WantsMessage |
OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage |
AcpOfferStatusSetMessage |
AcpOffersMessage |
ErrorMessage;
type OfferStatusSetParams = {
id: UUID,
status: Status,
};
type WantRmParams = {
id: UUID,
};
+25
View File
@@ -0,0 +1,25 @@
import { PrismaClient } from '@prisma/client'
import createDiscogs from './discogs';
import createOffers from './offers';
import createLog from './log';
import createApi from './api';
import createEvents from './events';
import createPg from './pg';
export default function creates(log) {
const prisma = new PrismaClient();
const pg = createPg(log, prisma);
const discogs = createDiscogs(log, pg);
const offers = createOffers(log, pg);
const events = createEvents(log, pg, discogs, offers);
const api = createApi(log, pg, discogs, offers, events);
return {
api,
discogs,
events,
pg,
prisma,
};
}
+19
View File
@@ -1,3 +1,5 @@
import type { Want, User } from '@prisma/client';
export function logAxiosError(e) {
console.warn({
status: e.response.status,
@@ -11,3 +13,20 @@ export function logAxiosError(e) {
},
});
}
export function wantDetails(want: Want) {
const data = want.data as any;
return {
title: data.title,
release: data.id,
artist: data.artists_sort,
id: want.id
}
}
export function isAdmin(user: User) {
return user.email === 'ntr@mnml.gg';
}
export function offerDetails() {}
+11 -3
View File
@@ -27,12 +27,15 @@
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": ["./index.d.ts"], /* Specify type package names to be included without being referenced in a source file. */
"types": [
"./index.d.ts",
"mocha"
], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
@@ -99,5 +102,10 @@
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
},
"ts-node": {
// strict mode gtfo
"transpileOnly": true,
},
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
parserOpts: { strictMode: false },
plugins: ['@babel/plugin-transform-react-jsx-source']
};
+31
View File
@@ -0,0 +1,31 @@
module.exports = {
parser: "@typescript-eslint/parser",
extends: [
"preact",
"plugin:@typescript-eslint/recommended"
],
ignorePatterns: [
"build/"
],
rules: {
// prevents stupid complaints a la
// (req) {
// req.something = x;
// }
'no-param-reassign': [2, { props: false }],
'no-multi-spaces': [0],
'max-len': ['error', 120],
'import/no-extraneous-dependencies': [0],
'prefer-arrow-callback': [0],
'arrow-body-style': [0],
'no-return-assign': 0,
'no-console': [0],
// i like loops
'no-plusplus': [0],
'no-await-in-loop': [0],
indent: ['error', 4],
'keyword-spacing': ['error'],
'key-spacing': ['error'],
},
};
+5
View File
@@ -0,0 +1,5 @@
node_modules
/build
/*.log
size-plugin.json
+19
View File
@@ -0,0 +1,19 @@
# mcrates
## CLI Commands
* `npm install`: Installs dependencies
* `npm run dev`: Run a development, HMR server
* `npm run serve`: Run a production-like server
* `npm run build`: Production-ready build
* `npm run lint`: Pass TypeScript files using ESLint
* `npm run test`: Run Jest and Enzyme with
[`enzyme-adapter-preact-pure`](https://github.com/preactjs/enzyme-adapter-preact-pure) for
your tests
For detailed explanation on how things work, checkout the [CLI Readme](https://github.com/developit/preact-cli/blob/master/README.md).
+2
View File
@@ -0,0 +1,2 @@
This application uses Discogs’ API but is not affiliated with, sponsored or endorsed by Discogs. ‘Discogs’ is a trademark of Zink Media, LLC.
Data provided by Discogs.
+37701
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"private": true,
"name": "mcrates-client",
"version": "0.1.0",
"license": "MIT",
"scripts": {
"build": "preact build --no-prerender",
"start": "preact --no-clear watch",
"lint": "eslint src"
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.5",
"lodash": "^4.17.21",
"logrocket": "^3.0.1",
"preact": "^10.10.0",
"preact-render-to-string": "^5.2.1",
"preact-router": "^3.2.1",
"react-feather": "^2.0.10",
"react-redux": "^8.0.2",
"redux": "^4.2.0"
},
"devDependencies": {
"@babel/plugin-transform-react-jsx-source": "^7.18.6",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"eslint": "^8.20.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-jest": "^26.7.0",
"jest": "^28.1.3",
"less": "^4.1.3",
"less-loader": "^7.3.0",
"preact-cli": "^3.4.1",
"typescript": "^4.5.2"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { route } from 'preact-router';
import http from './http';
const actions = store => ({
setWs: function setWs(state, ws) {
return { ws };
},
setUser: function setUser(state, user) {
return { user };
},
setOffers: function setOffers(state, offers) {
return { offers };
},
logout: async function logout() {
await http.postLogout();
route('/login');
return { user: null };
},
});
export default actions;
+64
View File
@@ -0,0 +1,64 @@
import { h } from 'preact';
import { useEffect } from 'preact/hooks';
import { useDispatch, Provider } from 'react-redux'
import { Route, Router, } from 'preact-router';
import LogRocket from 'logrocket';
import createWs from './ws';
import store, { wsState } from './store'
import Nav from './components/nav';
import Offers from './routes/offers';
import Acp from './routes/acp';
import Login from './routes/login';
import Search from './routes/search';
import List from './routes/list';
import Info from './routes/info';
if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates');
const Main = () => {
const dispatch = useDispatch();
// i have no idea why this is needed,
// but it makes sure the user changes actually rerender
useEffect(() => {
const ws = createWs(dispatch);
ws.connect();
dispatch(wsState.actions.set(ws));
}, [dispatch])
function routeChange(e) {
const { ws } = store.getState();
if (!ws) return null;
if (e.url === '/acp') return ws.sendAcpOffers();
}
return (
<main id="app">
<Nav />
<Router onChange={routeChange}>
<Route path="/" component={Offers} />
<Route path="/login" component={Login} mode="login" />
<Route path="/register" component={Login} mode="register" />
<Route path="/search" component={Search} />
<Route path="/list" component={List} />
<Route path="/info" component={Info} />
{/* ACP */}
<Route path="/acp" component={Acp} />
</Router>
</main>
);
};
const App = () => {
return (
<Provider store={store}>
<Main />
</Provider>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+337
View File
@@ -0,0 +1,337 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="swirl.fix.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
id="svg8"
version="1.1"
viewBox="0 0 96.148266 82.95547"
height="313.53247"
width="363.39502">
<defs
id="defs2">
<inkscape:path-effect
effect="perspective-envelope"
up_left_point="-158.75,85.333336"
up_right_point="159.45,85.333336"
down_left_point="-158.75,111.79167"
down_right_point="158.75,111.79167"
id="path-effect6876"
is_visible="true"
deform_type="perspective"
horizontal_mirror="false"
vertical_mirror="false"
overflow_perspective="false" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffebcd"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="14.481547"
inkscape:cx="181.99653"
inkscape:cy="193.62635"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
showborder="true"
inkscape:window-width="2556"
inkscape:window-height="1402"
inkscape:window-x="2560"
inkscape:window-y="34"
inkscape:window-maximized="0"
units="px"
fit-margin-top="8"
fit-margin-right="8"
fit-margin-bottom="8"
fit-margin-left="8"
inkscape:pagecheckerboard="true">
<inkscape:grid
type="xygrid"
id="grid815"
spacingx="2.6458333"
spacingy="2.6458333"
originx="-36.831378"
originy="-89.812155" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
style="display:inline"
transform="translate(-36.831378,-124.23238)">
<path
inkscape:connector-curvature="0"
id="path10242"
d="m 39.087803,177.13337 v -22.9279 l 45.818557,27.79778 45.81855,-27.71837 v 22.892 l -45.818559,27.75426 z"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10244"
d="M 39.087803,154.20547 84.906351,126.48711 130.72491,154.28488 84.906351,182.03914 39.087794,154.20547"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10396"
d="m 84.90636,182.03914 v 22.892"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 43.6823,155.2907 84.251848,129.27328 126.12932,153.20325 85.560615,179.25297 43.6823,155.2907"
id="path11658"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11660"
d="m 47.84146,156.15882 35.887132,-24.36344 38.240578,20.54299 -35.88561,24.39248 -38.2421,-20.57203"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 51.603832,156.84186 31.713373,-22.76505 34.888705,17.58143 -31.711318,22.79115 -34.89076,-17.60753"
id="path11662"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11664"
d="M 55.004869,157.36773 83.000788,136.13907 114.80407,151.13498 86.810601,172.38708 55.00486,157.36773"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 58.077109,157.76072 82.76466,138.00191 111.7311,150.74435 87.046302,170.52419 58.0771,157.76072"
id="path11666"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11668"
d="M 60.850409,158.0419 82.596088,139.68344 108.95716,150.46527 87.214448,168.84259 60.850409,158.0419"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 63.352121,158.22952 19.131893,-17.02926 23.970846,9.07928 -19.128774,17.04616 -23.973965,-9.09618"
id="path11670"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11672"
d="m 65.607289,158.33937 16.811604,-15.77181 21.780277,7.60381 -16.808406,15.78696 -21.783475,-7.61896"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 67.638806,158.38507 14.753693,-14.58584 19.774661,6.32796 -14.750438,14.59939 -19.777916,-6.34151"
id="path11674"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11676"
d="m 69.467606,158.37833 12.930142,-13.47036 17.940192,5.22734 -12.926902,13.48245 -17.943432,-5.23943"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 71.112789,158.32915 11.315792,-12.42379 16.263793,4.28033 -11.312595,12.43461 -16.26699,-4.29115"
id="path11678"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11680"
d="m 72.591771,158.24614 9.888034,-11.44417 14.733241,3.46781 -9.884899,11.45383 -14.736376,-3.47747"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 73.920439,158.1366 8.626548,-10.52918 13.33708,2.77287 -8.62352,10.53779 -13.340117,-2.78148"
id="path11682"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11684"
d="m 75.113233,158.00675 7.513182,-9.67626 12.064591,2.18051 -7.510251,9.68394 -12.067531,-2.18819"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 76.183315,157.86181 6.531587,-8.88266 10.905765,1.67756 -6.528763,8.88949 -10.908598,-1.68439"
id="path11686"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11688"
d="m 77.142637,157.70619 5.667137,-8.14552 9.851348,1.25235 -5.664445,8.15161 -9.854048,-1.25844"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 78.002061,157.54358 4.906768,-7.46198 8.892683,0.89464 -4.904201,7.46738 -8.89525,-0.90004"
id="path11690"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11694"
d="m 78.771436,157.37702 4.238774,-6.82908 8.02175,0.5954 -4.236341,6.83388 -8.024183,-0.6002"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 79.459703,157.20905 3.652706,-6.24394 7.231124,0.34668 -3.650397,6.2482 -7.233433,-0.35094"
id="path11696"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11698"
d="m 80.074972,157.04169 3.1392,-5.70366 6.51395,0.14155 -3.137033,5.70744 -6.516117,-0.14533"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:#f4f1d0;fill-opacity:1;stroke:#f4f1d0;stroke-width:0.39687499;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 80.624568,156.87663 2.689946,-5.2055 5.863887,-0.0261 -2.687912,5.20886 -5.86593,0.0228"
id="path11700"
inkscape:connector-curvature="0" />
<g
style="stroke:#f4f1d0;stroke-width:4.46899033;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="g9298"
transform="matrix(0.08880641,0,0,0.08880641,-71.602691,3.991392)">
<path
id="path5442"
inkscape:connector-curvature="0"
d="m 1888.0249,2040.4803 -52.9167,32.054 v 34.3699 l 52.9167,-32.054"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
id="path5444"
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
d="m 1925.0665,2035.2276 29.1042,-0.4447"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
id="path5446"
inkscape:connector-curvature="0"
d="m 1927.7124,2033.6249 -26.4584,16.027"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
id="path5448"
inkscape:connector-curvature="0"
d="m 1901.254,2066.8368 v -34.3699 l 39.6875,-24.0404 13.2292,9.1714 -26.4583,16.027"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
id="path5450"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1967.3999,2026.7694 26.4583,-50.3968 26.4583,18.3429"
inkscape:connector-curvature="0" />
<path
id="path5452"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1980.629,2001.571 26.4584,-16.027"
inkscape:connector-curvature="0" />
<path
id="path5454"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 2020.3166,1960.3456 52.9167,-32.0539"
inkscape:connector-curvature="0" />
<path
id="path5456"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 2046.7749,1944.3187 v 34.3699"
inkscape:connector-curvature="0" />
<path
id="path5458"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 2086.4624,1954.6481 v -34.3699 l 52.9167,-32.0539"
inkscape:connector-curvature="0" />
<path
id="path5460"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 2086.4624,1937.4632 26.4584,-16.027"
inkscape:connector-curvature="0" />
<path
id="path5462"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 2086.4624,1954.6481 52.9167,-32.0539"
inkscape:connector-curvature="0" />
<path
id="path5464"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.46899033;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 2205.5249,1848.157 -52.9166,32.0538 v 17.185 l 52.9166,-32.0539 v 17.185 l -52.9166,32.0538"
inkscape:connector-curvature="0" />
</g>
<g
style="stroke:#f4f1d0;stroke-width:3.91797233;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="g9259"
transform="matrix(0.08880641,0.05387819,0,0.11554215,6.1924204,89.232392)">
<g
style="stroke:#f4f1d0;stroke-width:3.91797233;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="translate(13.215028,6.61458)"
id="g9255">
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 463.02082,495.4375 v -26.45833 l 26.45834,13.22916 26.45833,-13.22916 v 26.45833"
inkscape:connector-curvature="0"
id="path9237" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 529.16665,495.4375 26.45834,-26.45833 26.45833,26.45833"
inkscape:connector-curvature="0"
id="path9239" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 542.39582,482.20833 h 26.45833"
inkscape:connector-curvature="0"
id="path9241" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 582.08333,468.97917 h 52.91666"
inkscape:connector-curvature="0"
id="path9243" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 608.54166,468.97917 V 495.4375"
inkscape:connector-curvature="0"
id="path9245" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 648.22916,495.4375 v -26.45833 h 52.91667"
inkscape:connector-curvature="0"
id="path9247" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 648.22916,482.20833 h 26.45833"
inkscape:connector-curvature="0"
id="path9249" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 648.22916,495.4375 h 52.91667"
inkscape:connector-curvature="0"
id="path9251" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.91797233;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 767.29166,468.97917 h -52.91667 v 13.22916 h 52.91667 v 13.22917 h -52.91667"
inkscape:connector-curvature="0"
id="path9253" />
</g>
</g>
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Box"
transform="translate(-36.831378,-124.23238)" />
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Text"
transform="translate(-36.831378,-124.23238)" />
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

+541
View File
@@ -0,0 +1,541 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="swirl.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
id="svg8"
version="1.1"
viewBox="0 0 94.568563 81.393395"
height="307.62857"
width="357.4245">
<defs
id="defs2">
<inkscape:path-effect
effect="perspective-envelope"
up_left_point="-158.75,85.333336"
up_right_point="159.45,85.333336"
down_left_point="-158.75,111.79167"
down_right_point="158.75,111.79167"
id="path-effect6876"
is_visible="true"
deform_type="perspective"
horizontal_mirror="false"
vertical_mirror="false"
overflow_perspective="false" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffebcd"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="234.71776"
inkscape:cy="105.70791"
inkscape:document-units="mm"
inkscape:current-layer="layer3"
showgrid="false"
showborder="true"
inkscape:window-width="2556"
inkscape:window-height="1402"
inkscape:window-x="2560"
inkscape:window-y="34"
inkscape:window-maximized="0"
inkscape:pagecheckerboard="true"
units="px"
fit-margin-top="5"
fit-margin-left="5"
fit-margin-right="5"
fit-margin-bottom="5">
<inkscape:grid
type="xygrid"
id="grid815"
spacingx="2.6458333"
spacingy="2.6458333"
originx="-118.41853"
originy="-209.93335" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Text"
transform="translate(-58.547806,-111.30139)">
<g
style="display:inline;stroke:#f4f1d0;stroke-opacity:1;stroke-width:4.47492335;stroke-miterlimit:4;stroke-dasharray:none"
id="g12059"
transform="matrix(0.08868867,0,0,0.08868867,-50.468219,-9.5066841)">
<g
id="g10220"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
transform="matrix(1,-0.60574282,0,1.2990197,876.00795,2035.6967)">
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1005.4167,475.69732 h -52.91672 v 26.45834 h 52.91672"
id="path10196"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1042.4583,488.92649 29.1042,13.22917"
id="path10198"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1045.1042,488.92649 h -26.4584"
id="path10200"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1018.6458,502.15566 v -26.45834 h 39.6875 l 13.2292,13.22917 h -26.4583"
id="path10202"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path10204"
d="m 1084.7917,502.15566 26.4583,-26.45833 26.4583,26.45833"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10206"
d="m 1098.0208,488.92649 h 26.4584"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10208"
d="m 1150.9375,475.69732 h 52.9167"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10210"
d="m 1177.3958,475.69732 v 26.45834"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10212"
d="M 1217.0833,502.15566 V 475.69732 H 1270"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10214"
d="m 1217.0833,488.92649 h 26.4584"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10216"
d="M 1217.0833,502.15566 H 1270"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
inkscape:connector-curvature="0"
id="path10218"
d="m 1336.1458,475.69732 h -52.9166 v 13.22917 h 52.9166 v 13.22917 h -52.9166"
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92624803;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
</g>
<g
transform="matrix(1,0.60669255,0,1.3010563,1504.3934,1849.0405)"
id="g10240"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill">
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M -158.75,111.79167 V 85.333336 l 26.45834,13.229164 26.45833,-13.229164 v 26.458334"
id="path10222"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M -92.604166,111.79167 -66.145832,85.333336 -39.6875,111.79167"
id="path10224"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m -79.374999,98.5625 h 26.458333"
id="path10226"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M -26.458333,85.333336 H 26.458333"
id="path10228"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 0,85.333336 V 111.79167"
id="path10230"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 39.6875,111.79167 V 85.333336 h 52.916666"
id="path10232"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 39.6875,98.5625 H 66.145832"
id="path10234"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 39.6875,111.79167 H 92.604166"
id="path10236"
inkscape:connector-curvature="0" />
<path
style="vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:3.92317387;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 158.75,85.333336 H 105.83333 V 98.5625 H 158.75 v 13.22917 h -52.91667"
id="path10238"
inkscape:connector-curvature="0" />
</g>
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1246.4246,1949.6563 v -258.1784 l 515.9375,313.0154 515.9374,-312.1212 v 257.7742 l -515.9375,312.5254 z"
id="path10242"
inkscape:connector-curvature="0" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1246.4246,1691.4779 515.9374,-312.1212 515.9375,313.0154 -515.9375,312.5254 -515.9375,-313.4196"
id="path10244"
inkscape:connector-curvature="0" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.3621,2004.8975 v 257.7742"
id="path10396"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11658"
d="m 1298.1607,1703.6981 456.8313,-292.9679 471.5591,269.4622 -456.8218,293.3316 -471.5686,-269.8259"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1344.9947,1713.4735 404.1052,-274.3433 430.6061,231.3233 -404.0881,274.6703 -430.6232,-231.6503"
id="path11660"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11662"
d="m 1387.3607,1721.1648 357.1068,-256.3447 392.8625,197.9748 -357.0837,256.6386 -392.8856,-198.2687"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1425.6579,1727.0864 315.2466,-239.0443 358.1192,168.8607 -315.219,239.3082 -358.1469,-169.1246"
id="path11664"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11666"
d="m 1460.2527,1731.5116 277.9929,-222.4931 326.1751,143.4856 -277.9619,222.7299 -326.2062,-143.7224"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1491.4813,1734.6778 244.8661,-206.7245 296.8375,121.4083 -244.8327,206.9368 -296.8709,-121.6206"
id="path11668"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11670"
d="m 1519.6517,1736.7905 215.4337,-191.7571 269.9225,102.2367 -215.3986,191.9474 -269.9576,-102.427"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1545.0459,1738.0275 189.3062,-177.5977 245.2557,85.6223 -189.2702,177.7682 -245.2917,-85.7928"
id="path11672"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11674"
d="m 1567.9217,1738.5421 166.1332,-164.2431 222.6716,71.2556 -166.0966,164.3957 -222.7082,-71.4082"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1588.5148,1738.4661 145.5992,-151.6822 202.0146,58.8621 -145.5627,151.8185 -202.0511,-58.9984"
id="path11676"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11678"
d="m 1607.0403,1737.9124 127.4209,-139.8975 183.1376,48.1985 -127.3849,140.0192 -183.1736,-48.3202"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1623.6943,1736.9776 111.3437,-128.8665 165.9029,39.0492 -111.3084,128.9752 -165.9382,-39.1579"
id="path11680"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11682"
d="m 1638.6557,1735.7442 97.1388,-118.5633 150.1815,31.2237 -97.1047,118.6603 -150.2157,-31.3207"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1652.0871,1734.282 84.6018,-108.959 135.8527,24.5535 -84.5688,109.0455 -135.8858,-24.64"
id="path11684"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11686"
d="m 1664.1367,1732.6499 73.5486,-100.0227 122.8038,18.8901 -73.5168,100.0996 -122.8357,-18.967"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1674.9391,1730.8976 63.8145,-91.7223 110.9306,14.1021 -63.7842,91.7907 -110.961,-14.1705"
id="path11688"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11690"
d="m 1684.6166,1729.0665 55.2524,-84.0253 100.1356,10.0741 -55.2235,84.0861 -100.1645,-10.1349"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1693.2801,1727.191 47.7305,-76.8986 90.3285,6.7045 -47.7031,76.9526 -90.3559,-6.7585"
id="path11694"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11696"
d="m 1701.0303,1725.2995 41.1311,-70.3095 81.4257,3.9038 -41.1051,70.3575 -81.4517,-3.9518"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1707.9585,1723.415 35.3488,-64.2258 73.35,1.5939 -35.3244,64.2684 -73.3744,-1.6365"
id="path11698"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11700"
d="m 1714.1472,1721.5563 30.29,-58.6162 66.03,-0.2941 -30.2671,58.654 -66.053,0.2563"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1719.6718,1719.7364 25.8707,-53.45 59.3996,-1.8202 -25.849,53.4835 -59.4213,1.7867"
id="path11702"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11704"
d="m 1724.5994,1717.9689 22.0164,-48.698 53.3985,-3.0369 -21.9962,48.7276 -53.4187,3.0073"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1728.9909,1716.2627 18.6609,-44.3319 47.9706,-3.9903 -18.6419,44.358 -47.9895,3.9642"
id="path11706"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11708"
d="m 1732.9019,1714.6241 15.7446,-40.3246 43.0646,-4.7202 -15.7268,40.3477 -43.0824,4.6971"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1736.3819,1713.0578 13.2153,-36.6507 38.6337,-5.2613 -13.1988,36.6711 -38.6502,5.2409"
id="path11710"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11712"
d="m 1739.4759,1711.5666 11.0263,-33.2856 34.6344,-5.6438 -11.0109,33.3036 -34.6498,5.6258"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1742.2244,1710.1521 9.1361,-30.2065 31.0273,-5.8937 -9.1217,30.2224 -31.0417,5.8778"
id="path11714"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11716"
d="m 1744.6639,1708.8147 7.5081,-27.3918 27.7762,-6.0334 -7.4948,27.4057 -27.7895,6.0195"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1746.8272,1707.554 6.1096,-24.8211 24.8479,-6.0826 -6.0973,24.8333 -24.8602,6.0704"
id="path11718"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11720"
d="m 1748.7438,1706.3688 4.912,-22.4753 22.2122,-6.0578 -4.9007,22.4859 -22.2235,6.0472"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1750.4402,1705.2573 3.8898,-20.3366 19.8413,-5.9734 -3.8793,20.3458 -19.8518,5.9642"
id="path11722"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11724"
d="m 1751.9404,1704.2174 3.0205,-18.3884 17.7103,-5.8416 -3.0109,18.3963 -17.7199,5.8337"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1753.2654,1703.2464 2.2844,-16.615 15.7962,-5.673 -2.2755,16.622 -15.8051,5.666"
id="path11726"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11728"
d="m 1754.4347,1702.3415 1.6639,-15.0021 14.078,-5.4761 -1.6557,15.0082 -14.0862,5.47"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1755.4655,1701.4999 1.1434,-13.5365 12.5369,-5.2585 -1.136,13.5418 -12.5443,5.2532"
id="path11730"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11732"
d="m 1756.3731,1700.7184 0.7097,-12.2056 11.1555,-5.0263 -0.703,12.2101 -11.1622,5.0218"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1757.1711,1699.9939 0.351,-10.9979 9.9181,-4.785 -0.3448,11.0019 -9.9243,4.781"
id="path11734"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11736"
d="m 1757.872,1699.3233 0.057,-9.9029 8.8105,-4.5387 -0.051,9.9064 -8.8162,4.5352"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1758.4869,1698.7034 -0.1823,-8.9109 7.8197,-4.2909 0.1874,8.9139 -7.825,4.2879"
id="path11738"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11740"
d="m 1759.0257,1698.1313 -0.3739,-8.0128 6.9342,-4.0447 0.3786,8.0154 -6.9392,4.0421"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1759.4969,1697.6039 -0.5251,-7.2002 6.1433,-3.8024 0.5296,7.2025 -6.1479,3.8001"
id="path11742"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11744"
d="m 1759.9085,1697.1183 -0.6422,-6.4656 5.4376,-3.5657 0.6462,6.4676 -5.4418,3.5637"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1760.2674,1696.6718 -0.73,-5.802 4.8081,-3.3361 0.7338,5.8036 -4.812,3.3345"
id="path11746"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11748"
d="m 1760.5797,1696.2616 -0.7935,-5.2028 4.2473,-3.1148 0.7969,5.2042 -4.2507,3.1134"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1760.8512,1695.8851 -0.8365,-4.6622 3.7478,-2.9024 0.8396,4.6635 -3.751,2.9011"
id="path11750"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11754"
d="m 1761.0865,1695.5401 -0.8624,-4.1749 3.3034,-2.6996 0.8653,4.176 -3.3064,2.6985"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1761.2901,1695.2241 -0.8743,-3.7358 2.9084,-2.5067 0.877,3.7368 -2.9112,2.5057"
id="path11756"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11758"
d="m 1761.4657,1694.935 -0.8745,-3.3404 2.5576,-2.3239 0.877,3.3414 -2.5602,2.3229"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1761.617,1694.6708 -0.8655,-2.9848 2.2463,-2.151 0.8678,2.9856 -2.2488,2.1502"
id="path11760"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11762"
d="m 1761.7469,1694.4295 -0.849,-2.665 1.9703,-1.9881 0.8511,2.6656 -1.9725,1.9875"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1761.8581,1694.2094 -0.8266,-2.3777 1.7257,-1.8351 0.8286,2.3782 -1.7278,1.8346"
id="path11764"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11766"
d="m 1761.9529,1694.0088 -0.7998,-2.1198 1.5093,-1.6917 0.8016,2.1201 -1.5112,1.6914"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.0335,1693.826 -0.7696,-1.8883 1.3181,-1.5575 0.7712,1.8885 -1.3198,1.5573"
id="path11928"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11930"
d="m 1762.1017,1693.6596 -0.7369,-1.6807 1.1492,-1.4323 0.7383,1.6808 -1.1507,1.4323"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.1591,1693.5084 -0.7025,-1.4948 1.0002,-1.3157 0.7037,1.4948 -1.0015,1.3157"
id="path11932"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11934"
d="m 1762.2072,1693.371 -0.6672,-1.3283 0.8688,-1.2074 0.6683,1.3283 -0.8699,1.2074"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.2472,1693.2462 -0.6315,-1.1794 0.7532,-1.1067 0.6325,1.1794 -0.7542,1.1067"
id="path11936"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11938"
d="m 1762.2802,1693.133 -0.5958,-1.0463 0.6516,-1.0134 0.5967,1.0463 -0.6525,1.0134"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.3073,1693.0304 -0.5607,-0.9274 0.5624,-0.927 0.5615,0.9273 -0.5632,0.9271"
id="path11940"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11942"
d="m 1762.3292,1692.9374 -0.5262,-0.8212 0.4842,-0.8472 0.5269,0.8211 -0.4849,0.8473"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.3467,1692.8533 -0.4926,-0.7265 0.4156,-0.7736 0.4933,0.7263 -0.4163,0.7738"
id="path11944"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path11946"
d="m 1762.3606,1692.7772 -0.4604,-0.6421 0.3557,-0.7058 0.461,0.6419 -0.3563,0.706"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill" />
<path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#f4f1d0;stroke-width:4.47492335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 1762.3713,1692.7084 -0.4294,-0.5669 0.3034,-0.6434 0.4299,0.5667 -0.3039,0.6436"
id="path11948"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 41 KiB

+81
View File
@@ -0,0 +1,81 @@
import { h } from 'preact';
import { route } from 'preact-router';
import { useEffect } from 'preact/hooks';
import { useSelector } from 'react-redux'
import LogRocket from 'logrocket';
import http from './../http';
import { State } from './../store';
import Swirl from './swirl';
function showAdmin(user) {
return user && user.email === 'ntr@mnml.gg';
}
const DEFAULT_ROUTE = '/';
const Nav = () => {
const user = useSelector((state: State) => state.user);
useEffect(() => {
if (!user) {
route('/login');
return;
}
route(DEFAULT_ROUTE)
if (window.location.host === 'matescrates.au') {
LogRocket.identify(user.id, {
name: user.email,
email: user.email,
});
}
return;
}, [user])
async function logoutClick(e) {
e.preventDefault();
await http.postLogout();
window.location.reload();
}
const nav = () => (
<nav>
<a href="/">Offers</a>
<a href='/list'>List</a>
<a href="/search">Search</a>
</nav>
);
const anonNav = () => (
<nav>
<a href="/login">Login</a>
<a href="/register">Register</a>
</nav>
);
const adminNav = () => (
<nav>
<a href='/acp'>Offers ACP</a>
</nav>
)
const userNav = () => (
<nav>
<a href="/info">Info</a>
<a href="/auth/logout" onClick={logoutClick}>Logout</a>
</nav>
)
return (
<header>
{user ? nav() : anonNav()}
{showAdmin(user) ? adminNav() : <div></div>}
{user ? userNav() : null}
</header>
);
}
export default Nav;
+76
View File
@@ -0,0 +1,76 @@
import { h } from 'preact';
import { ExternalLink, Check } from 'react-feather';
import { useState } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux'
import { State } from './../store';
import http from '../http';
function OfferLine(offer) {
const ws = useSelector((state: State) => state.ws);
function setStatus(status) {
ws.sendOfferStatusSet(offer.id, status);
}
const accepted = offer.status === 'Accepted';
const classes = `${accepted ? 'accepted' : ''}`;
const btnText = accepted
? offer.status
: 'Accept';
const negBtn = accepted
? <button onClick={() => setStatus('Pending')} class='neg'>Cancel</button>
: <button onClick={() => setStatus('Declined')} class='neg'>Decline</button>;
return (
<div class="line">
<div class='number'>${offer.total}</div>
<div class='number'>{offer.condition}</div>
<button onClick={() => setStatus('Accepted')} disabled={accepted} class={classes} >{btnText}</button>
{negBtn}
</div>
)
}
function OfferPriceBtn(offer) {
const ws = useSelector((state: State) => state.ws);
const accepted = offer.status === 'Accepted';
const confirmed = offer.status === 'Confirmed';
function setStatus() {
if (accepted) return ws.sendOfferStatusSet(offer.id, 'Pending')
ws.sendOfferStatusSet(offer.id, 'Accepted');
}
const classes = `${accepted ? 'accepted' : ''} ${confirmed ? 'confirmed' : ''}`;
const btnText = accepted
? offer.status
: 'Accept';
return (
<button disabled={confirmed} onClick={() => setStatus()} class={classes}>${offer.total} {offer.condition}</button>
)
}
export default function OfferGroup(group, i) {
const ws = useSelector((state: State) => state.ws);
return (
<div class="offer" key={i}>
<section>
<h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank"><ExternalLink /></a></h3>
<div class='alt-text details'>
{group.details.year} - {group.details.formats.map(f => f.descriptions.join(' ')).join(' ')}
</div>
<div class='alt-text details'>Status: {group.status}</div>
</section>
<img src={group.details.thumb} />
<div class="lines">
{group.offers.map(o => OfferPriceBtn(o))}
</div>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { h } from 'preact';
import { ExternalLink, Check } from 'react-feather';
import { useState } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux'
import { State } from './../store';
import http from '../http';
export default function Offer(offer) {
const ws = useSelector((state: State) => state.ws);
const { want: { data: details } } = offer;
function setStatus(status) {
ws.sendOfferStatusSet(offer.id, status);
}
const accepted = offer.status === 'Accepted';
const classes = `${accepted ? 'accepted' : ''}`;
const btnText = accepted
? offer.status
: 'Accept';
const negBtn = accepted
? <button onClick={() => setStatus('Pending')} class='neg'>Cancel</button>
: <button onClick={() => setStatus('Declined')} class='neg'>Decline</button>;
return (
<div class="offer">
<section>
<h3>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h3>
<div class='alt-text' >{details.year}</div>
<div class='alt-text'>{details.formats[0].descriptions.join(' ')}</div>
<div class='number'>mcrates price: ${offer.total}</div>
<div class='number'>shipped by itself: ${offer.comparison}</div>
</section>
<img src={details.thumb} />
<button onClick={() => setStatus('Accepted')} disabled={accepted} class={classes} >{btnText}</button>
{negBtn}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { h } from 'preact';
export default function Swirl() {
return <div class='swirl' />;
}
+41
View File
@@ -0,0 +1,41 @@
import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import { ExternalLink } from 'react-feather';
import { useSelector } from 'react-redux'
import { State } from './../store'
import http from './../http';
function Want(want) {
const r = want.data;
const ws = useSelector((state: State) => state.ws);
const [confirm, setConfirm] = useState(false);
function onClick() {
if (confirm) return ws.sendWantRm(want.id);
setTimeout(() => setConfirm(false), 2500);
return setConfirm(true);
};
const text = confirm
? 'confirm'
: 'remove';
return (
<article key={r.id}>
<div>
<h3>{r.artists_sort} - {r.title} <a href={r.uri} target="_blank"><ExternalLink /></a></h3>
</div>
<img src={r.thumb} />
<div class='details'>
<div class='alt'>{r.year}</div>
<div class='alt'>{r.formats[0].descriptions.join(' ')}</div>
</div>
<button onClick={onClick} >{text}</button>
</article>
);
}
export default Want;
+4
View File
@@ -0,0 +1,4 @@
declare module "*.less" {
const mapping: Record<string, string>;
export default mapping;
}
+95
View File
@@ -0,0 +1,95 @@
import axios, { AxiosInstance } from 'axios';
type ApiResponse = [string | null, any | null];
declare module 'axios' {
export interface AxiosInstance {
request(config: AxiosRequestConfig): Promise<ApiResponse>;
get(url: string, config?: AxiosRequestConfig): Promise<ApiResponse>;
delete(url: string, config?: AxiosRequestConfig): Promise<ApiResponse>;
head(url: string, config?: AxiosRequestConfig): Promise<ApiResponse>;
post(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse>;
put(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse>;
patch(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse>;
}
}
const api = axios.create({
baseURL: '/api',
withCredentials: true
});
function onSuccess(response) {
return [null, response.data];
}
function onError(error) {
if (error.response) return [error.response.data.error, null];
if (error.request) return ['Network Error', null];
return ['Unknown Error', null];
}
api.interceptors.response.use(onSuccess, onError);
const actions = {
getBundles: async function getBundles() {
return api.get('/acp/bundles');
},
getOffers: async function getOffers() {
return api.get('/user/offers');
},
getOffersAcp: async function getOffersAcp() {
return api.get('/acp/offers');
},
getWants: async function getWants() {
return api.get('/want/all');
},
postWantSearch: async function postWantSearch(query) {
return api.post('/want/search', { query });
},
postWant: async function postWant(releaseId) {
return api.post('/want', { releaseId });
},
postDiscogs: async function postDiscogs(username) {
return api.post('/want/discogs', { username });
},
getLogin: async function getLogin() {
return api.get('/auth/login')
},
postLogin: async function postLogin(email, password) {
return api.post('/auth/login', {
email,
password,
// discogs,
});
},
postLogout: async function postLogout() {
return api.post('/auth/logout', {});
},
postRegister: async function postRegister(email, password) {
return api.post('/auth/register', {
email,
password,
});
},
postOffers: async function postOffers(listingIds, shipping, baseShipping) {
return api.post('/offers', {
listingIds,
shipping,
baseShipping,
});
},
};
export default actions;
+4
View File
@@ -0,0 +1,4 @@
import './style/index.less';
import App from './app';
export default App;
+21
View File
@@ -0,0 +1,21 @@
{
"name": "mcrates",
"short_name": "mcrates",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#fff",
"theme_color": "#673ab8",
"icons": [
{
"src": "/assets/icons/android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/assets/icons/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+92
View File
@@ -0,0 +1,92 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import http from './../http';
import { useSelector, useDispatch } from 'react-redux'
import { State } from './../store';
enum Status {
Accepted = 'Accepted',
Pending = 'Pending',
Declined = 'Declined',
Confirmed = 'Confirmed',
Complete = 'Complete',
}
const POSITIVE_STATUS = [Status.Accepted, Status.Confirmed, Status.Complete];
function Item({ item }) {
const ws = useSelector((state: State) => state.ws);
console.log(item);
const statuses = Object.entries(Status).map(([label, value]) => (
<option selected={item.status === value} value={value}>{value}</option>
));
function onChange(e) {
ws.sendAcpOfferStatusSet(item.id, e.target.value);
}
const bold = POSITIVE_STATUS.includes(item.status)
? 'bold'
: '';
return (
<div key={item.id} class={bold}>
<a href={item.listing.data.uri} target="_blank" >
<h4>{item.listing.data.release.description}</h4>
</a>
<h5>{item.user.email}</h5>
<h5 class='alt-text'>{item.total}</h5>
<h5>{item.status}</h5>
<label>Set Status:</label>
<select onChange={onChange}>
{statuses}
</select>
</div>
);
}
function SellerGroup(group) {
const ready = group.offers.filter(item => POSITIVE_STATUS.includes(item.status)).length;
return (
<div class="bundle">
<section>
<h2>{group.username} - {group.seller}</h2>
<h3>{ready} / {group.offers.length} offers</h3>
<h4>Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}</h4>
</section>
<div class='items'>
{group.offers.map(i => <Item item={i} />)}
</div>
</div>
)
}
function Home() {
const offers = useSelector((state: State) => state.acpOffers);
const ws = useSelector((state: State) => state.ws);
// useEffect(() => {
// ws.sendAcpOffers();
// }, [offers, ws]);
if (!offers.length) {
return (
<div class={'offers'}>
loading...
</div>
);
}
return (
<div class='bundles'>
{offers.map(SellerGroup)}
</div>
);
}
export default Home;
+55
View File
@@ -0,0 +1,55 @@
import { h } from 'preact';
import Swirl from './../components/swirl';
const Info = () => {
return (
<div class='info'>
<Swirl />
<div class='words'>
<section>
<h1>Info</h1>
<p> Thanks for signing up! </p>
<p> Mates Crates is a record importing service in alpha. </p>
<p> Its purpose is to reduce the cost of shipping vinyl records to Melbourne, Australia. </p>
</section>
<section>
<h2>How It Works</h2>
<p> First, add some records to your <span class='alt'>List</span> on the <a href='/search'>Search Page</a>. </p>
<p> Mates Crates will then make <span class='alt'>Offers</span> to you for releases you add to your <a href='/list'>List</a>. </p>
<p> Select all <span class='alt'>Offers</span> you are interested in, the more you accept the more likely the item will be <span class='alt-text'>Confirmed</span>.</p>
<p> Check back regularly for updated <span class='alt'>Offers</span>, the global record marketplace is a living thing. </p>
</section>
<section>
<h2>Offers</h2>
<p> Not all items on your <span class='alt'>List</span> will have offers. However, as new users join the site the number of offers we can make increases, so be sure to tell your friends!</p>
<p> Think of offers like an expression of interest, they are not confirmed until they are set to <span class='alt-text'>Confirmed</span> status. </p>
<p> After that they will be shipped to Melbourne and ready for pick up. </p>
</section>
<section>
<h2>Pick Up</h2>
<p> Use the contact options to get in touch. </p>
<p> Payment is cash on delivery. </p>
</section>
<section>
<h2>FAQ</h2>
<h3> Delivery Times </h3>
<p> Records are usually available for pickup within a week. We order in bulk, which often means express, insured shipping to Melbourne. </p>
<h3> Friends </h3>
<p> Please invite other collectors to the site. More users means offers can become cheaper and shipped sooner. </p>
</section>
</div>
<section class='contact'>
<h2>Contact</h2>
<p><a href='mailto:ntr@mnml.gg?subject=Mates%20Crates%20Enquiry'>Email</a></p>
<p><a href='https://discord.gg/QfjY8WNQyx'>Discord</a></p>
</section>
</div>
);
};
export default Info;
+34
View File
@@ -0,0 +1,34 @@
import { h } from 'preact';
import { useSelector } from 'react-redux'
import { route } from 'preact-router';
import { State } from './../store'
import Want from './../components/want';
interface Props {
user: any,
wants: any[],
getUserOffers: (userId: string) => void,
}
const List = ({ user }: Props) => {
const wants = useSelector((state: State) => state.wants);
if (!wants) {
return <div> loading... </div>;
}
if (!wants.length) {
route('/search');
}
return (
<div class="list">
{wants.map(Want)}
</div>
);
};
export default List;
+117
View File
@@ -0,0 +1,117 @@
import { h } from 'preact';
import { route } from 'preact-router';
import { useState, useEffect, useRef } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux'
import http from './../http';
import { State, userState } from './../store';
import Swirl from './../components/swirl';
import OfferGroup from './../components/offer.group';
const Login = ({ mode }) => {
const dispatch = useDispatch()
const user = useSelector((state: State) => state.user);
const ws = useSelector((state: State) => state.ws);
const [email, setEmail] = useState('')
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
async function postLogin() {
const [error, user] = await http.postLogin(email, password);
if (error) return setError(error);
route('/');
dispatch(userState.actions.set(user));
// reconnect ws to get logged in methods
ws.connect();
}
async function postRegister() {
const [error, user] = await http.postRegister(email, password);
if (error) return setError(error);
route('/search');
dispatch(userState.actions.set(user));
}
function handleSubmit(e) {
e.preventDefault();
if (mode === 'login') return postLogin()
if (mode === 'register') return postRegister();
}
const emailRef = useRef<HTMLInputElement>()
useEffect(() => {
emailRef.current.focus()
}, [])
const errorEl = error
? <div class='alt-text'>{error}</div>
: null;
if (mode === 'register') return (
<div class="login">
{errorEl}
<form action="">
<div class="item">
<label for="email">email</label>
<input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} />
</div>
<div class="item">
<label for="password">password</label>
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
</div>
<div class="item">
<label for="password">confirm password</label>
<input type="password" name="password" id="password" value={confirmPassword} required onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)} />
</div>
<div class="item">
<input type="submit" value="register" onClick={handleSubmit} />
</div>
</form>
</div>
);
return (
<div class="login">
{errorEl}
<form action="">
<div class="item">
<label for="email">email</label>
<input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} />
</div>
<div class="item">
<label for="password">password</label>
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
</div>
<div class="item">
<input type="submit" value="login" onClick={handleSubmit} />
</div>
</form>
</div>
);
};
function LoginPage({ mode }) {
const offers = useSelector((state: State) => state.offers);
return (
<div class="landing">
<Login mode={mode} />
<div class='offers'>
{offers.map(OfferGroup)}
<div>
<Swirl />
<div class='words'>
<p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p>
<p>Based in Preston.</p>
</div>
</div>
</div>
</div>
)
}
export default LoginPage;
+28
View File
@@ -0,0 +1,28 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { useSelector, useDispatch } from 'react-redux'
import http from './../http';
import { State, offersState } from './../store';
import OfferGroup from './../components/offer.group';
function Home() {
const dispatch = useDispatch()
const offers = useSelector((state: State) => state.offers);
if (offers && offers.length === 0) {
route('/search');
}
if (!offers) return <div>loading...</div>;
return (
<div class={'offers'}>
{offers.map(OfferGroup)}
</div>
);
}
export default Home;
+116
View File
@@ -0,0 +1,116 @@
import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks';
import { ExternalLink, Check } from 'react-feather';
import http from '../http';
function Result(r) {
const [added, setAdded] = useState(false)
async function onClick() {
const [error, want] = await http.postWant(r.id);
if (error) return false;
setAdded(true);
}
const classes = `${added ? 'added' : ''}`;
const btnText = added
? <Check />
: '+';
return (
<article key={r.id}>
<button onClick={onClick} disabled={added} class={classes} >{btnText}</button>
<div>
<h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank"><ExternalLink /></a></h3>
<h5 class='alt-text' >{r.year}</h5>
<h5 class='alt-text'>{r.country}</h5>
<h5 class='alt-text'>{r.format.join(' ')}</h5>
</div>
<img src={r.thumb} />
</article>
);
}
const User = () => {
const [query, setQuery] = useState('')
const [title, setTitle] = useState('');
const [username, setUsername] = useState('');
const [results, setResults] = useState([]);
const [infoText, setInfoText] = useState('');
async function fetchResults() {
const [error, results] = await http.postWantSearch(query);
if (results.length === 0) {
setResults([]);
return setInfoText('No results');
}
setInfoText('');
setResults(results);
}
async function submitWantlist() {
const [error, wants] = await http.postDiscogs(username);
if (wants.length === 0) {
setResults([]);
return setInfoText('No new additions');
}
setInfoText(`Added ${wants.length} items to your list.`);
}
const searchRef = useRef<HTMLInputElement>()
useEffect(() => {
searchRef.current.focus()
}, [])
function handleSubmit(e) {
e.preventDefault();
fetchResults();
}
function handleDiscogsSubmit(e) {
e.preventDefault();
submitWantlist();
}
const topEl = results.length
? <button onClick={() => window.scrollTo(0, 0)}> top </button>
: null;
const infoEl = infoText
? <article><div></div><div>{infoText}</div></article>
: null;
return (
<div class='search'>
<div>
<form action="" method="get">
<label for="query">Query</label>
<input type="text" name="query" ref={searchRef} placeholder="ben klock subzero" id="query" value={query} onChange={e => setQuery((e.target as HTMLInputElement).value) } required />
<input type="submit" value="Search" onClick={handleSubmit} />
</form>
<hr />
<div>
<form action="" method="get">
<label for="Discogs">Discogs Username</label>
<input type="text" name="username" placeholder="Discogs Username" id="username" value={username} onChange={e => setUsername((e.target as HTMLInputElement).value) } required />
<input type="submit" value="Sync Wantlist" onClick={handleDiscogsSubmit} />
</form>
</div>
<hr />
<p>Data provided by Discogs.</p>
</div>
<div class='results'>
{infoEl}
{results.map(Result)}
{topEl}
</div>
</div>
);
};
export default User;
+61
View File
@@ -0,0 +1,61 @@
import { createStore, combineReducers } from 'redux'
import { configureStore, createSlice } from '@reduxjs/toolkit'
export const userState = createSlice({
name: 'user',
initialState: null,
reducers: {
set: (state, action) => action.payload
},
});
export const offersState = createSlice({
name: 'offers',
initialState: [],
reducers: {
set: (state, action) => action.payload
},
});
export const acpOffersState = createSlice({
name: 'acpOffers',
initialState: [],
reducers: {
set: (state, action) => action.payload
},
});
export const wantsState = createSlice({
name: 'wants',
initialState: null,
reducers: {
set: (state, action) => action.payload
},
});
export const wsState = createSlice({
name: 'ws',
initialState: null,
reducers: {
set: (state, action) => action.payload
},
});
const reducer = combineReducers({
offers: offersState.reducer,
wants: wantsState.reducer,
ws: wsState.reducer,
user: userState.reducer,
acpOffers: acpOffersState.reducer,
});
const store = createStore(
reducer,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
export default store;
export type State = ReturnType<typeof store.getState>
export type Dispatch = typeof store.dispatch
+541
View File
@@ -0,0 +1,541 @@
// Digital Ocean reset
html {
box-sizing: border-box;
font-size: 16px;
}
*, *:before, *:after {
box-sizing: inherit;
}
body, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
font-weight: normal;
}
ol, ul {
list-style: none;
}
img {
// max-width: 100%;
height: auto;
}
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/raleway/v28/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvaorCIPrE.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcecodepro/v21/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQtMlrTA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: normal;
src: url(https://fonts.gstatic.com/s/notosansmono/v20/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ093dVQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
// stuff from UX laws
html {
color:var(--base-font-color);
font-size:clamp(var(--min-font-size),calc(50% + 1vw),var(--max-font-size));
font-family:var(--base-font-family);
line-height:var(--base-line-height);
-webkit-text-size-adjust:100%;
background:var(--background-0);
box-sizing:border-box
}
// @charset "UTF-8";
:root {
--breakpoint-medium: 35em;
--breakpoint-large: 56em;
--small-font-size: 0.75em;
--base-font-size: 1em;
--smedium-font-size: 1.333em;
--medium-font-size: 1.777em;
--large-font-size: 2.369em;
--xlarge-font-size: 3.157em;
--base-font-family: 'Raleway', Arial, Sans-Serif;
--secondary-font-family: 'Noto Sans Mono', monospace;
--base-font-color: var(--color-off-white);
--secondary-font-color: var(--color-mid-grey);
--base-line-height: 1.4;
--header-line-height: 1.2;
--base-border-color: var(--secondary-font-color);
--base-border: 2px solid var(--base-border-color);
--hover-border: 2px solid var(--base-font-color);
--focus-border: 2px solid var(--base-font-color);
--base-border-radius: .2rem;
--base-focus: 2px solid var(--base-accent-color);
--base-outline-offset: 0.25em;
--min-font-size: var(--base-font-size);
--max-font-size: var(--large-font-size);
--color-dark-blue: #101014;
--color-mid-blue: #16171d;
--color-light-blue: #1f2128;
--color-mid-grey: #9BA0B3;
--color-off-white: #F4F1D0;
--color-accent-blue: #5E9ED6;
--color-blue-gray: #506485;
--color-blue: #475977;
--color-green: #476d4a;
--color-accepted: var(--color-blue);
--color-confirmed: var(--color-green);
--base-accent-color: var(--color-accent-blue);
--fg-color: var(--color-off-white);
--hover-link-color: var(--color-off-white);
--background-0: var(--color-dark-blue);
--background-1: var(--color-mid-blue);
--background-2: var(--color-light-blue);
--spacing: 1rem;
--spacing-half: calc(var(--spacing)/2);
--spacing-double: calc(var(--spacing)*2);
--utility-bar-header-height: calc(var(--spacing) * 1.5);
}
button, input, optgroup, select, textarea {
margin:0;
border:var(--base-border);
border-radius:var(--base-border-radius);
background:0 0;
font-family:inherit;
font-size:100%;
color:inherit;
line-height:var(--base-line-height);
}
input:hover, textarea:hover {
color:var(--base-font-color);
border:var(--hover-border)
}
input:focus, textarea:focus {
border:var(--focus-border);
outline:0
}
button, input {
overflow:visible;
}
button, select {
text-transform: none;
}
button, [type=button], [type=reset], [type=submit] {
-webkit-appearance:button;
border-color: transparent;
cursor:pointer;
font-family: var(--secondary-font-family);
font-size: var(--small-font-size);
font-weight: bold;
text-transform: uppercase;
&:hover {
background: var(--background-2);
border-color: transparent;
}
}
h1, h2, h3, h4, h5, h6 {
margin-top:0;
line-height:var(--header-line-height);
}
h1 {
margin-bottom:var(--spacing);
font-size:var(--large-font-size)
}
h2 {
margin-bottom:var(--spacing);
font-size:var(--medium-font-size);
}
h3 {
margin-bottom:calc(var(--spacing)/2);
font-size:var(--smedium-font-size)
}
h4 {
margin-bottom:calc(var(--spacing)/3);
font-size:var(--base-font-size)
}
p {
margin:0 0 var(--spacing)
}
// ok actual styles
body {
padding-top: calc(var(--spacing)/2);
}
main {
padding: 0 var(--spacing-double) 0 var(--spacing-double);
}
button, [type=submit] {
padding: var(--spacing-half);
background: var(--background-1);
}
.landing {
display: grid;
grid-template-columns: 1fr 2fr;
grid-gap: calc(var(--spacing-double) * 2);
grid-template-areas:
"login swirl"
"login sample"
"login words";
.login {
grid-area: login;
button {
width: 100%;
}
}
.swirl {
// grid-area: swirl;
height: 5rem;
}
.words {
grid-area: words;
text-align: center;
}
}
.info {
display: grid;
grid-template-areas:
"words swirl"
"words contact";
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content 1fr;
section {
margin-bottom: var(--spacing);
}
.contact {
grid-area: contact;
text-align: center;
}
.words {
grid-area: words;
}
.swirl {
grid-area: swirl;
height: 15rem;
}
}
.alt-text, .alt {
font-family: var(--secondary-font-family);
// font-size: var(--small-font-size);
text-transform: uppercase;
}
article {
background-color: var(--background-1);
button {
border-color: var(--background-2);
}
}
.search {
display: grid;
grid-template-columns: 1fr 2fr;
.results {
display: grid;
padding-left: var(--spacing);
grid-gap: var(--spacing);
article {
display: grid;
padding: var(--spacing);
grid-template-columns: 1fr 4fr min-content;
}
}
button {
margin-right: var(--spacing);
background: var(--background-2);
border-color: var(--background-1);
&.added {
background: var(--color-green);
cursor: auto;
}
}
}
.list {
display: grid;
grid-template-columns:
1fr 1fr 1fr;
grid-gap: var(--spacing);
article {
display: grid;
// grid-template-rows:
// 1fr min-content min-content;
grid-template-areas:
"title img"
"details details"
"remove remove";
button {
margin-top: var(--spacing-half);
grid-area: remove
}
.details {
grid-area: details;
}
padding: var(--spacing);
img {
justify-self: end;
}
}
}
form {
label, input {
display: block;
}
input, button {
width: 100%;
}
input {
padding: var(--spacing-half);
margin-bottom: var(--spacing);
}
label {
font-family: var(--secondary-font-family);
font-size: var(--small-font-size);
text-transform: uppercase;
margin-bottom: var(--spacing-half);
}
}
a {
color: var(--secondary-font-color);
font-family: var(--secondary-font-family);
text-transform: uppercase;
&:hover {
color: var(--fg-color);
}
}
nav {
a {
font-size: var(--small-font-size);
margin-right: var(--spacing);
}
}
header {
display: grid;
grid-template-columns: 1fr 1fr min-content;
padding-bottom: var(--spacing);
a {
white-space: nowrap;
}
}
.bundles {
flex-flow: row;
.bundle {
margin-bottom: var(--spacing-double);
section {
margin-bottom: var(--spacing);
}
.items {
display: grid;
grid-template-columns: repeat(5, 1fr);
div {
margin-right: var(--spacing);
margin-bottom: var(--spacing);
display: grid;
&.bold {
font-weight: bold;
background: var(--background-2);
}
}
}
}
}
.offers {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing);
.offer {
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: min-content min-content;
grid-template-areas:
"details img"
"lines lines";
padding: var(--spacing);
background: var(--background-1);
.details {
font-size: var(--small-font-size);
}
.status {
margin-top: var(--spacing-half);
}
section {
grid-area: details;
}
img {
grid-area: img;
justify-self: end;
margin-left: var(--spacing-half);
}
.lines {
grid-area: lines;
display: flex;
flex-wrap: wrap;
column-gap: var(--spacing-half);
button {
margin-top: var(--spacing-half);
border-color: var(--background-2);
&.accepted {
background: var(--color-accepted);
}
&.confirmed {
cursor: auto;
background: var(--color-confirmed);
}
&.neg {
margin-left: var(--spacing-half);
}
}
}
}
}
.number {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}
.swirl {
background-image: url("./../assets/swirl.fix.svg");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: var(--spacing);
}
@media (max-width:900px) {
.landing {
grid-template-columns: 1fr;
grid-template-areas:
"swirl"
"login"
"words";
}
.list {
grid-template-columns: 1fr;
}
.search {
grid-template-columns: 1fr;
}
.offers {
grid-template-columns: 1fr;
}
.info {
grid-template-columns: 1fr;
// is necessary for some reason
grid-template-areas:
"swirl"
"contact"
"words";
}
main {
padding: 0 var(--spacing) 0 var(--spacing);
}
}
@media (max-width:1200px) {
img {
width: 5rem;
}
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>matescrates.au - record import service</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<% preact.headEnd %>
</head>
<body>
<% preact.bodyEnd %>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
import { wantsState, wsState, userState, offersState, acpOffersState } from './store'
const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`;
export default function createSocket(dispatch) {
let ws;
// -------------
// Outgoing
// -------------
function send(msg) {
if (msg[0] !== 'Ping') console.log('outgoing msg', msg);
if (ws.readyState !== 1) return false;
try {
ws.send(JSON.stringify(msg));
} catch (e) {
console.warn(e);
}
}
// let ping;
function sendPing() {
// ping = Date.now();
send(['Ping', {}]);
// setTimeout(sendPing, 50);
}
function sendOfferStatusSet(id, status) {
send(['OfferStatusSet', { id, status }]);
}
function sendWantRm(id) {
send(['WantRm', { id }]);
}
function sendOffers() {
send(['Offers', {}]);
}
function sendWants() {
send(['Wants', {}]);
}
function sendAcpOffers() {
send(['AcpOffers', {}]);
}
function sendAcpOfferStatusSet(id, status) {
send(['AcpOfferStatusSet', { id, status }]);
}
let pongTimeout;
function onPong() {
pongTimeout = setTimeout(sendPing, 5000);
}
// -------------
// Incoming
// -------------
function onUser(user) {
dispatch(userState.actions.set(user));
}
function onWants(wants) {
dispatch(wantsState.actions.set(wants));
}
function onOffers(offers) {
dispatch(offersState.actions.set(offers));
}
function onAcpOffers(offers) {
dispatch(acpOffersState.actions.set(offers));
}
// -------------
// Setup
// -------------
// when the server sends a reply it will have one of these message types
// this object wraps the reply types to a function
const handlers = {
Wants: onWants,
Offers: onOffers,
User: onUser,
OfferSamples: onOffers,
AcpOffers: onAcpOffers,
Pong: onPong,
Error: errHandler,
};
function errHandler(error) {
// store.dispatch(http.LIBRARY_EVENT, { level: 'error', log: error });
console.error(error);
}
function onMessage(event) {
const res = event.data;
const [msgType, params] = JSON.parse(res);
if (msgType !== 'Pong') {
console.log(msgType, params);
}
// check for error and split into response type and data
if (!handlers[msgType]) return console.error(`${msgType} handler missing`);
return handlers[msgType](params);
}
// Connection opened
function onOpen() {
console.log('websocket connected', SOCKET_URL)
sendPing();
sendAcpOffers();
sendOffers();
sendWants();
}
function onError(event) {
console.error('WebSocket error', event);
}
function onClose(event) {
console.error('WebSocket closed', event);
return setTimeout(connect, 2000);
}
function connect() {
if (ws) {
clearTimeout(pongTimeout);
ws.removeEventListener('open', onOpen);
ws.removeEventListener('message', onMessage);
ws.removeEventListener('error', onError);
ws.removeEventListener('close', onClose);
ws = null;
}
console.log('connecting to websocket', SOCKET_URL);
ws = new WebSocket(SOCKET_URL);
// Listen for messages
ws.addEventListener('open', onOpen);
ws.addEventListener('message', onMessage);
ws.addEventListener('error', onError);
ws.addEventListener('close', onClose);
return ws;
}
return {
connect,
sendOfferStatusSet,
sendWantRm,
sendOffers,
sendAcpOffers,
sendAcpOfferStatusSet,
};
}
+64
View File
@@ -0,0 +1,64 @@
{
"compilerOptions": {
/* Basic Options */
"target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
"module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation: */
"allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"jsxFactory": "h", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */
"jsxFragmentFactory": "Fragment", /* Specify the JSX fragment factory function to use when targeting react JSX emit with jsxFactory compiler option is specified, e.g. Fragment. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": false, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"esModuleInterop": true, /* */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"react": ["./node_modules/preact/compat"],
"react-dom": ["./node_modules/preact/compat"]
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */
},
"include": ["src/**/*"]
}
+35
View File
@@ -0,0 +1,35 @@
version: "3.3"
services:
pg:
image: postgres:latest
environment:
POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'mcrates'
POSTGRES_PASSWORD: 'digdeep'
# allow prisma client to push changes from outside docker etc
# ufw blocks this from being publicly accessible
ports:
- '45432:5432'
volumes:
- "./db:/var/lib/postgresql/data"
api:
image: node:16
working_dir: /opt/mcrates/api
entrypoint: npm start
environment:
DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
volumes:
- "./api:/opt/mcrates/api"
nginx:
image: nginx:latest
volumes:
- './nginx/matescrates.au.nginx.conf:/etc/nginx/conf.d/default.conf'
- './client/build:/srv/matescrates'
- '/etc/letsencrypt:/etc/letsencrypt'
ports:
- '80:80'
- '443:443'
+21
View File
@@ -0,0 +1,21 @@
version: "3.3"
services:
pg:
image: postgres:latest
environment:
POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'test'
POSTGRES_PASSWORD: 'digdeep'
api:
image: node:16
working_dir: /opt/mcrates/api
entrypoint: npm run test:watch
depends_on:
- pg
environment:
DATABASE_URL: "postgresql://mcrates:digdeep@pg:5432/test?schema=public"
volumes:
- "./api:/opt/mcrates/api"
+30
View File
@@ -11,3 +11,33 @@ services:
- '45432:5432'
volumes:
- "./db:/var/lib/postgresql/data"
api:
image: node:16
working_dir: /opt/mcrates/api
depends_on:
- 'pg'
entrypoint: npm start
environment:
DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
volumes:
- "./api:/opt/mcrates/api"
client:
image: node:16
working_dir: /opt/mcrates/client
entrypoint: npm start
ports:
- '8080:8080' # fwd the hmr websocket
volumes:
- "./client:/opt/mcrates/client"
- "./api:/opt/mcrates/api"
nginx:
image: nginx:latest
depends_on:
- 'api'
volumes:
- './nginx/localhost.nginx.conf:/etc/nginx/conf.d/default.conf'
ports:
- '80:80'
@@ -1,8 +0,0 @@
# #!/bin/bash
# set -e
# psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
# CREATE USER mcrates;
# CREATE DATABASE mcrates;
# GRANT ALL PRIVILEGES ON DATABASE mcrates TO mcrates;
# EOSQL
-93
View File
@@ -1,93 +0,0 @@
import fs from 'fs/promises';
import _ from 'lodash';
import Promise from 'bluebird';
import discogsService from './src/discogs';
import pgService from './src/pg';
import { logAxiosError } from './src/utils';
async function start() {
const pg = pgService();
const discogs = discogsService(pg);
const usernames = [
'margh',
'jmcdermid1',
'vanderbabe',
];
async function fetchListings(w) {
return w.map(discogs.listings)
}
async function populateUserListings(user) {
console.log(user);
const wants = await discogs.wantlist(user);
console.log(user.discogs, 'has', wants.length, 'wants');
for (const want of wants) {
const listings = await discogs.listings(want);
const populated = listings.filter(l => l);
console.log(want.id, 'has', populated.length, '/', listings.length, 'listings');
}
// const listings = await Promise
// .all(releases.map(async want => {
// const listings = (await getListings(want)).filter(l => l);
// const insertions = await Promise.all(listings.map(async l => {
// const listing = await pg.listingUpsert(l, want);
// console.log(listing.id, listing.data.release.description, want.user, listing.seller);
// return listing;
// }));
// console.log(insertions.length, want.id);
// }));
// return listings;
}
return Promise.resolve(usernames)
.map(u => discogs.user(u))
.map(populateUserListings);
// return Promise.map(releases, async w => {
// try {
// const l = await discogs.listings(w.id)
// return l;
// } catch (e) {
// // console.error(e);
// return null;
// }
// })
// .then(listings => _
// .chain(listings)
// .filter(l => l)
// .flatten()
// // .map(l => ({
// // id: l.id,
// // description: l.release.description,
// // seller_id: l.seller.id,
// // seller_name: l.seller.username,
// // }))
// // .groupBy(l => l.seller_id)
// .value()
// );
}
function run() {
start()
.then(r => {
console.log(r);
setTimeout(run, 60 * 1000);
})
.catch(e => {
console.error(e);
if (e.message === 'rate limited') {
return setTimeout(run, 60 * 1000);
}
process.exit(1);
});
}
run();
-17
View File
@@ -1,17 +0,0 @@
module.exports = {
client: 'postgresql',
connection: {
host : '0.0.0.0',
port: 45432,
database: 'mcrates',
user: 'mcrates',
password: 'digdeep',
},
pool: {
min: 2,
max: 10
},
migrations: {
tableName: 'knex_migrations'
}
};
-17
View File
@@ -1,17 +0,0 @@
exports.up = async knex => {
await knex.schema.createTable('users', table => {
table.uuid('id')
.primary()
.defaultTo(knex.raw('gen_random_uuid()'));
table.string('discogs')
.unique();
table.jsonb('data')
.notNullable();
table.timestamps(true, true);
});
};
exports.down = async () => true;
-27
View File
@@ -1,27 +0,0 @@
exports.up = async knex => {
await knex.schema.createTable('wants', table => {
table.uuid('id')
.primary()
.defaultTo(knex.raw('gen_random_uuid()'));
table.integer('release')
.notNullable()
.index();
table.uuid('user')
table.foreign('user')
.references('id')
.inTable('users')
.onDelete('CASCADE');
table.jsonb('data')
.notNullable();
// user can only want a release once
table.unique(['user', 'release']);
table.timestamps(true, true);
});
};
exports.down = async () => true;
-23
View File
@@ -1,23 +0,0 @@
exports.up = async knex => {
await knex.schema.createTable('listings', table => {
table.integer('id')
.primary();
table.uuid('want')
table.foreign('want')
.references('id')
.inTable('wants')
.onDelete('CASCADE');
table.integer('seller')
.notNullable()
.index();
table.jsonb('data')
.notNullable();
table.timestamps(true, true);
});
};
exports.down = async () => true;
+34
View File
@@ -0,0 +1,34 @@
upstream mcrates_api {
server api:41337;
}
upstream mcrates_client {
server client:8080;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location / {
proxy_pass http://mcrates_client;
proxy_read_timeout 600s;
}
location /api/ws {
proxy_pass http://mcrates_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
location /api/ {
# trailing slash means api will receive url WITHOUT /api
proxy_pass http://mcrates_api/;
proxy_read_timeout 600s;
}
}
+54
View File
@@ -0,0 +1,54 @@
upstream mcrates_api {
server api:41337;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
server_name matescrates.au;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
location / {
root /srv/matescrates;
index index.html;
try_files $uri $uri/ index.html;
}
location /api/ws {
proxy_pass http://mcrates_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
location /api/ {
# trailing slash means api will receive url WITHOUT /api
proxy_pass http://mcrates_api/;
proxy_read_timeout 600s;
}
error_page 404 /;
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/matescrates.au/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/matescrates.au/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# http -> https
server {
server_name matescrates.au;
return 301 https://$host$request_uri;
}
-3352
View File
File diff suppressed because it is too large. Load diff
-30
View File
@@ -1,30 +0,0 @@
{
"name": "mates-crates",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"migrate": "knex migrate:latest",
"migrate:make": "prisma migrate dev --name",
"start": "ts-node-dev --respawn --transpile-only index.ts"
},
"author": "",
"license": "ISC",
"devDependencies": {
"node-dev": "^7.4.3",
"prisma": "^4.1.0",
"ts-node-dev": "^2.0.0",
"typescript": "^4.7.4"
},
"dependencies": {
"@prisma/client": "^4.1.0",
"axios": "^0.27.2",
"bluebird": "^3.7.2",
"bunyan": "^1.8.15",
"express": "^4.18.1",
"knex": "^2.2.0",
"lodash": "^4.17.21",
"pg": "^8.7.3"
}
}
@@ -1,13 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"discogs" TEXT,
"data" JSONB NOT NULL,
"creaedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_discogs_key" ON "User"("discogs");
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-20
View File
@@ -1,20 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
discogs String? @unique
data Json
creaedAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
-79
View File
@@ -1,79 +0,0 @@
import fs from 'fs/promises';
import axios from 'axios';
import cheerio from 'cheerio';
import { logAxiosError } from './utils';
import { Pg } from './pg';
export default function discogs(pg: Pg) {
const TOKEN = 'wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
const apiBase = 'https://api.discogs.com'
const discogsBase = 'https://www.discogs.com'
const api = axios.create({
headers: {
'Authorization': TOKEN,
},
});
function handleApiError(e) {
logAxiosError(e);
if (e.response.status === 429) {
throw new Error('rate limited');
}
}
async function user(discogs: string) {
const user = await pg.userGetName(discogs);
if (user) return user;
console.log('fetching user', discogs);
const res = await api.get(`${apiBase}/users/${discogs}`);
return pg.userUpsert(res.data);
}
async function wantlist(user: User): Promise<Want[]> {
const stored = await pg.userWants(user);
if (stored.length) return stored;
const { data: { wants, pagination }} = await api.get(`${apiBase}/users/${user.data.username}/wants`);
return Promise.all(wants.map(r => pg.wantUpsert(r, user)))
}
async function inventory(user: string) {
const res = await api.get(`${apiBase}/users/${user}/inventory`);
return res.data.listings;
}
async function listings(want: Want): Promise<Listing[]> {
const res = await api.get(`https://www.discogs.com/sell/release/${want.release}`);
const $ = cheerio.load(res.data);
const ids = $('.item_description_title[href]')
.map(function getListingId() {
const id = Number.parseInt(this.attribs.href.replace('/sell/item/', ''), 10);
return id;
})
.get();
return Promise.all(ids.map(id => listing(id, want)));
}
async function listing(id, want) {
const listing = await pg.listingGet(id);
if (listing) return listing;
try {
const res = await api.get(`${apiBase}/marketplace/listings/${id}`);
return pg.listingUpsert(res.data, want);
} catch (e) {
handleApiError(e);
return null;
}
}
return {
user,
wantlist,
inventory,
listings,
listing,
};
}
-21
View File
@@ -1,21 +0,0 @@
type UUID = string;
type User = {
id: UUID,
discogs: string,
data: any,
}
type Listing = {
id: number,
want: UUID,
seller: number,
data: any,
}
type Want = {
id: UUID,
release: number,
user: UUID,
data: any,
}
-98
View File
@@ -1,98 +0,0 @@
import Knex from 'knex';
import config from '../knexfile';
export interface Pg {
userGetId: (id: UUID) => Promise<User>,
userGetName: (name: string) => Promise<User>,
userUpsert: (user: User) => Promise<User>,
userWants: (user: User) => Promise<Want[]>,
wantUpsert: (want: Want, user: User) => Promise<Want>,
listingUpsert: (listing: Listing, want: Want) => Promise<Listing>,
listingGet: (id: number) => Promise<Listing>,
}
export default function createPg(): Pg {
const pg = Knex(config);
async function userGetId(id): Promise<User> {
return pg('users')
.where({ id })
.first();
}
async function userGetName(name): Promise<User> {
return pg('users')
.where({ discogs: name })
.first();
}
async function userWants(user: User): Promise<Want[]> {
return pg('wants')
.where({ user: user.id });
}
async function userUpsert(user): Promise<User> {
const [row] = await pg('users')
.insert({
discogs: user.username,
data: user,
})
.onConflict('id')
.merge()
.onConflict('discogs')
.merge()
.returning('*');
return row;
}
async function wantUpsert(want, user): Promise<Want> {
const [row] = await pg('wants')
.insert({
data: want,
release: want.id,
user: user.id,
})
.onConflict('id')
.merge()
.onConflict(['user', 'release'])
.merge()
.returning('*');
return row;
}
async function listingUpsert(listing, want): Promise<Listing> {
const [row] = await pg('listings')
.insert({
id: listing.id,
want: want.id,
seller: listing.seller.id,
data: listing,
})
.onConflict('id')
.merge()
.returning('*');
return row;
}
async function listingGet(id): Promise<Listing> {
return pg('listings')
.where({ id })
.first();
}
return {
userGetId,
userGetName,
userUpsert,
userWants,
wantUpsert,
listingUpsert,
listingGet,
};
}