Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99b148e15 | ||
|
|
ffc023f419 |
No files matched your search
@@ -1,11 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
db/
|
||||
|
||||
.env
|
||||
|
||||
*sql.gz
|
||||
crates/prisma/generated/client
|
||||
crates.sublime-workspace
|
||||
crates/static/css
|
||||
crates/prisma/generated
|
||||
db/
|
||||
@@ -1,5 +0,0 @@
|
||||
- The aim is to convert the existing websocket client application to a Deno Fresh serverside rendered one with the minimum amount of change to styling and functionality.
|
||||
- don't add tailwind styles unless directed
|
||||
- in serverside rendered components, do not use clientside hooks such as usestate
|
||||
- copy files when possible instead of reading them in and writing out
|
||||
- useState hooks cannot be used in ssr components, such components must be converted to fresh islands
|
||||
@@ -1,30 +1,18 @@
|
||||
# TODO
|
||||
|
||||
- accept new offers that are under highest accepted price
|
||||
|
||||
env
|
||||
discogs api key
|
||||
discord api key
|
||||
|
||||
discord bot
|
||||
notify on sign up
|
||||
want added
|
||||
offer accepted
|
||||
|
||||
email ppl who signed up
|
||||
say we have updates
|
||||
|
||||
main list filters
|
||||
order by
|
||||
offers only
|
||||
|
||||
sample offer with tooltips
|
||||
|
||||
go over and reorganise event logic
|
||||
should be connection.service ?
|
||||
|
||||
contact options on landing page
|
||||
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
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,7 @@
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
DATABASE_URL="postgresql://mcrates:digdeep@0.0.0.0:45432/mcrates?schema=public"
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM node:20
|
||||
WORKDIR /opt/crates/api
|
||||
|
||||
# puppeteer deps
|
||||
RUN apt-get update
|
||||
# don't think about it
|
||||
RUN apt-get install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils
|
||||
|
||||
COPY . ./
|
||||
RUN npm i
|
||||
# not sure why doesn't do it itself
|
||||
RUN node node_modules/puppeteer/install.mjs
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -1 +0,0 @@
|
||||
crates-api-7b8d754768-r4f5q 19:40:38.635Z ERROR mcrates: sync error ││ crates-api-7b8d754768-r4f5q TypeError: Cannot read properties of undefined (reading 'status') ││ crates-api-7b8d754768-r4f5q at populateListings (/opt/crates/api/src/events.ts:42:28) ││ crates-api-7b8d754768-r4f5q at updateWantListings (/opt/crates/api/src/events.ts:57:30) ││ crates-api-7b8d754768-r4f5q at EventEmitter.sync (/opt/crates/api/src/events.ts:83:13) ││ crates-api-7b8d754768-r4f5q 19:40:40.707Z INFO mcrates: (event=Sync, wants=192) ││ crates-api-7b8d754768-r4f5q 19:42:14.756Z ERROR mcrates: Navigation timeout of 30000 ms exceeded ││ crates-api-7b8d754768-r4f5q TimeoutError: Navigation timeout of 30000 ms exceeded ││ crates-api-7b8d754768-r4f5q at new Deferred (/opt/crates/api/node_modules/puppeteer-core/src/util/Deferred.ts:73:2 ││ crates-api-7b8d754768-r4f5q at Function.create (/opt/crates/api/node_modules/puppeteer-core/src/util/Deferred.ts:2 ││ crates-api-7b8d754768-r4f5q at new LifecycleWatcher (/opt/crates/api/node_modules/puppeteer-core/src/cdp/Lifecycle ││ crates-api-7b8d754768-r4f5q at CdpFrame.goto (/opt/crates/api/node_modules/puppeteer-core/src/cdp/Frame.ts:159:21) ││ crates-api-7b8d754768-r4f5q at CdpFrame.<anonymous> (/opt/crates/api/node_modules/puppeteer-core/src/util/decorato ││ crates-api-7b8d754768-r4f5q at CdpPage.goto (/opt/crates/api/node_modules/puppeteer-core/src/api/Page.ts:1649:35) ││ crates-api-7b8d754768-r4f5q at Object.listings (/opt/crates/api/src/discogs.ts:80:32) ││ crates-api-7b8d754768-r4f5q at populateListings (/opt/crates/api/src/events.ts:37:30) ││ crates-api-7b8d754768-r4f5q at updateWantListings (/opt/crates/api/src/events.ts:57:30) ││ crates-api-7b8d754768-r4f5q at EventEmitter.sync (/opt/crates/api/src/events.ts:83:13) ││ crates-api-7b8d754768-r4f5q 19:42:14.989Z ERROR mcrates: sync error ││ crates-api-7b8d754768-r4f5q TypeError: Cannot read properties of undefined (reading 'status') ││ crates-api-7b8d754768-r4f5q at populateListings (/opt/crates/api/src/events.ts:42:28) ││ crates-api-7b8d754768-r4f5q at updateWantListings (/opt/crates/api/src/events.ts:57:30) ││ crates-api-7b8d754768-r4f5q at EventEmitter.sync (/opt/crates/api/src/events.ts:83:13) ││ crates-api-7b8d754768-r4f5q 19:42:25.047Z INFO mcrates: (event=Sync, wants=202) ││ crates-api-7b8d754768-r4f5q 19:44:01.988Z ERROR mcrates: Timed out after waiting 30000ms ││ crates-api-7b8d754768-r4f5q TimeoutError: Timed out after waiting 30000ms ││ crates-api-7b8d754768-r4f5q at /opt/crates/api/node_modules/puppeteer-core/src/common/util.ts:302:17 ││ crates-api-7b8d754768-r4f5q at /opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_party/rxjs/rxjs.js:3986:3 ││ crates-api-7b8d754768-r4f5q at OperatorSubscriber2._this._next (/opt/crates/api/node_modules/puppeteer-core/lib/cj ││ crates-api-7b8d754768-r4f5q at OperatorSubscriber2.Subscriber2.next (/opt/crates/api/node_modules/puppeteer-core/l ││ crates-api-7b8d754768-r4f5q at AsyncAction2.<anonymous> (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third ││ crates-api-7b8d754768-r4f5q at AsyncAction2._execute (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_pa ││ crates-api-7b8d754768-r4f5q at AsyncAction2.execute (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_par ││ crates-api-7b8d754768-r4f5q at AsyncScheduler2.flush (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_pa ││ crates-api-7b8d754768-r4f5q at listOnTimeout (node:internal/timers:581:17) ││ crates-api-7b8d754768-r4f5q at processTimers (node:internal/timers:519:7) ││ crates-api-7b8d754768-r4f5q 19:44:04.209Z ERROR mcrates: sync error ││ crates-api-7b8d754768-r4f5q TypeError: Cannot read properties of undefined (reading 'status') ││ crates-api-7b8d754768-r4f5q at populateListings (/oLine truncated
|
||||
@@ -24,16 +24,12 @@ async function start() {
|
||||
prisma,
|
||||
pg,
|
||||
discogs,
|
||||
discord,
|
||||
api,
|
||||
events,
|
||||
notify,
|
||||
} = await createServices(log);
|
||||
} = createServices(log);
|
||||
|
||||
await notify.listen();
|
||||
await discord.connect();
|
||||
|
||||
// events.emit(Events.Sync);
|
||||
// events.emit(Events.StartupSync);
|
||||
// events.emit(Events.ListingsUpdated);
|
||||
|
||||
api.listen();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "crates",
|
||||
"name": "mates-crates",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "mocha",
|
||||
"test:watch": "mocha --watch",
|
||||
"migrate": "prisma migrate status",
|
||||
"migrate": "knex migrate:latest",
|
||||
"migrate:make": "prisma migrate dev --name",
|
||||
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
|
||||
},
|
||||
@@ -21,6 +21,7 @@
|
||||
"@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"
|
||||
@@ -33,8 +34,6 @@
|
||||
"bluebird": "^3.7.2",
|
||||
"bunyan": "^1.8.15",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"discord.js": "^14.7.1",
|
||||
"esrun": "^3.2.26",
|
||||
"koa": "^2.13.4",
|
||||
"koa-body": "^5.0.0",
|
||||
"koa-passport": "^5.0.0",
|
||||
@@ -42,11 +41,7 @@
|
||||
"koa-websocket": "^7.0.0",
|
||||
"koa-ws": "^1.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg-listen": "^1.7.0",
|
||||
"puppeteer": "^23.3.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||
"passport-local": "^1.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"koa-passport": {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated"
|
||||
moduleFormat = "esm"
|
||||
provider = "prisma-client-js"
|
||||
// include for node docker image
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
previewFeatures = ["interactiveTransactions"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -10,13 +11,8 @@ datasource db {
|
||||
}
|
||||
|
||||
model Listing {
|
||||
|
||||
// example listingId 2148851036
|
||||
// works fine as a JS number
|
||||
// but JS numbers are cast to int4 for pg by prisma
|
||||
// becomes -2146116260
|
||||
id BigInt @id
|
||||
seller BigInt
|
||||
id Int @id
|
||||
seller Int
|
||||
data Json
|
||||
|
||||
wantId String @db.Uuid
|
||||
@@ -25,7 +21,7 @@ model Listing {
|
||||
offers Offer[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([seller])
|
||||
@@map("listings")
|
||||
@@ -37,7 +33,7 @@ model User {
|
||||
password String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
wants Want[]
|
||||
offers Offer[]
|
||||
@@ -47,14 +43,14 @@ model User {
|
||||
|
||||
model Want {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
release BigInt
|
||||
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 @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
listings Listing[]
|
||||
offers Offer[]
|
||||
@@ -75,7 +71,7 @@ model Offer {
|
||||
shipping Decimal @db.Money
|
||||
total Decimal @db.Money
|
||||
|
||||
listingId BigInt
|
||||
listingId Int
|
||||
listing Listing @relation(fields: [listingId], references: [id])
|
||||
|
||||
userId String @db.Uuid
|
||||
@@ -84,9 +80,6 @@ model Offer {
|
||||
wantId String @db.Uuid
|
||||
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
// only one offer per want for a user
|
||||
// @@unique([userId, wantId])
|
||||
@@unique([listingId])
|
||||
@@ -48,4 +48,12 @@ describe('api', function apiTests() {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,9 @@ import Bluebird from 'bluebird';
|
||||
import type Log from 'bunyan';
|
||||
import type { Listing, User, Want, Prisma } from '@prisma/client';
|
||||
|
||||
import type { Pg } from './pg';
|
||||
import type { Discogs } from './discogs';
|
||||
import type { Offers } from './offers';
|
||||
import type { Rpc } from './rpc';
|
||||
|
||||
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';
|
||||
@@ -29,11 +27,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
||||
|
||||
const app = koaWebsocket(new koa());
|
||||
|
||||
// @ts-ignore - ts don't know wtf it talking about
|
||||
BigInt.prototype.toJSON = function bigIntStringify() {
|
||||
return this.toString();
|
||||
};
|
||||
|
||||
app.use(async (ctx, next) => {
|
||||
try {
|
||||
await next();
|
||||
@@ -51,7 +44,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
||||
function listen() {
|
||||
const server = app.listen(PORT);
|
||||
log.info({ PORT }, 'listening');
|
||||
return server;
|
||||
}
|
||||
|
||||
app.use(cors({ credentials: true }));
|
||||
@@ -60,7 +52,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
||||
app.keys = [SECRET];
|
||||
app.use(session(app));
|
||||
|
||||
const { authRouter, passport } = routerAuth(log, pg, events);
|
||||
const { authRouter, passport } = routerAuth(log, pg);
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session())
|
||||
app.use(authRouter.routes());
|
||||
@@ -69,13 +61,37 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
||||
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;
|
||||
ctx.assert(username, 'no username supplied');
|
||||
const user = await pg.userFindId(ctx.state.user.id);
|
||||
const wants = await discogs.wantListSync(username, user);
|
||||
events.emit(Events.WantsAdded, wants);
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -104,8 +120,9 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
||||
// log.child...
|
||||
|
||||
const ws = ctx.websocket;
|
||||
const rpc = createRpc(log, pg, ws, discogs, offers, events, ctx.state.user as User | null);
|
||||
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());
|
||||
@@ -123,9 +140,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
|
||||
log.info('ws disconnected', { user: ctx.state.user });
|
||||
});
|
||||
|
||||
if (ctx.state.user) events.emit(Events.Connection, { rpc, id: ctx.state.user.id });
|
||||
await rpc.onConnection();
|
||||
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import axios from 'axios';
|
||||
import pptr from 'puppeteer-extra'
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
|
||||
import cheerio from 'cheerio';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
import type Log from 'bunyan';
|
||||
import type { Want, User } from '@prisma/client';
|
||||
import type { PrismaClient, Want, User, Listing } from '@prisma/client';
|
||||
|
||||
import { wantDetails } from './utils';
|
||||
import { logAxiosError, wantDetails } from './utils';
|
||||
import { Pg } from './pg';
|
||||
|
||||
export default async function createDiscogs(log: Log, pg: Pg) {
|
||||
const TOKEN = process.env.DISCOGS_TOKEN;
|
||||
// margh
|
||||
// vanderbabe
|
||||
// jmcdermid1
|
||||
|
||||
export default function createDiscogs(log: Log, pg: Pg) {
|
||||
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
|
||||
const apiBase = 'https://api.discogs.com'
|
||||
|
||||
pptr.use(StealthPlugin())
|
||||
const browser = await pptr.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] })
|
||||
|
||||
log.info('browser created')
|
||||
const discogsBase = 'https://www.discogs.com'
|
||||
|
||||
const api = axios.create({
|
||||
headers: {
|
||||
@@ -25,37 +27,34 @@ export default async function createDiscogs(log: Log, pg: Pg) {
|
||||
},
|
||||
});
|
||||
|
||||
function onSuccess(response) {
|
||||
return response.data;
|
||||
async function handleApiError(e) {
|
||||
logAxiosError(e);
|
||||
if (e.response.status === 429) {
|
||||
throw new Error('rate limited');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onError(error) {
|
||||
throw error;
|
||||
}
|
||||
api.interceptors.response.use(onSuccess, onError);
|
||||
|
||||
async function wantListSync(username: string, user: User): Promise<Want[]> {
|
||||
const res = await api.get(`${apiBase}/users/${username}/wants`);
|
||||
const { wants, pagination } = res;
|
||||
|
||||
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)));
|
||||
log.info(results);
|
||||
// @ts-ignore
|
||||
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
||||
|
||||
log.info('want list additions', { additions: additions.length });
|
||||
log.info(additions);
|
||||
|
||||
return additions;
|
||||
}
|
||||
|
||||
async function release(id: DiscogsRelease): Promise<any> {
|
||||
const data = await api.get(`${apiBase}/releases/${id}`);
|
||||
const { data } = await api.get(`${apiBase}/releases/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -64,59 +63,44 @@ export default async function createDiscogs(log: Log, pg: Pg) {
|
||||
url.searchParams.append('query', query);
|
||||
url.searchParams.append('type', 'release');
|
||||
url.searchParams.append('format', 'vinyl');
|
||||
const res = await api.get(url.href);
|
||||
const { results } = res;
|
||||
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.listings;
|
||||
return res.data.listings;
|
||||
}
|
||||
|
||||
async function listings(want: Want) {
|
||||
const page = await browser.newPage()
|
||||
const url = `https://www.discogs.com/sell/release/${want.release}?limit=250`
|
||||
const res = await page.goto(url)
|
||||
log.debug({ status: res.status() }, 'status')
|
||||
if (!res.ok()) throw new Error(res.statusText())
|
||||
const els = await page.$$('.item_description_title[href]')
|
||||
const hrefs = await Promise.all(els.map(e => e.evaluate(e => e.href)))
|
||||
const ids = hrefs
|
||||
.map(h => new URL(h))
|
||||
.map(p => p.pathname.replace('/sell/item/', ''))
|
||||
.map(p => Number.parseInt(p, 10))
|
||||
|
||||
await page.close()
|
||||
|
||||
if (ids.length === 0) log.warn({ res, url, ...wantDetails(want) }, "Want had zero listings")
|
||||
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
|
||||
|
||||
// sometimes we get dodgy listings from discogs that throw perm errors
|
||||
// we just want to ignore them
|
||||
// const results = await Promise.allSettled(ids.map(id => listing(id, want)));
|
||||
|
||||
const results = []
|
||||
for await (const id of ids) {
|
||||
const r = await listing(id, want)
|
||||
results.push(r)
|
||||
}
|
||||
return results;
|
||||
|
||||
return Bluebird.mapSeries(ids, async id => listing(id, want));
|
||||
}
|
||||
|
||||
async function listing(id, want) {
|
||||
const listing = await pg.listingFind(id);
|
||||
if (listing) return listing;
|
||||
|
||||
log.debug('fetching listing', id);
|
||||
const discogsListing = await api.get(`${apiBase}/marketplace/listings/${id}`);
|
||||
log.debug(discogsListing);
|
||||
return pg.listingUpsert(discogsListing, want);
|
||||
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 {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type Log from 'bunyan';
|
||||
|
||||
import { Client, GatewayIntentBits } from 'discord.js';
|
||||
|
||||
export default function createDiscord(log: Log) {
|
||||
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
const TOKEN = process.env.DISCORD_TOKEN;
|
||||
|
||||
async function connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.once('ready', () => {
|
||||
log.info({ bot: client.user.tag }, 'discord client logged in');
|
||||
resolve(client);
|
||||
});
|
||||
client.login(TOKEN);
|
||||
});
|
||||
}
|
||||
|
||||
function message(msg) {
|
||||
if (!process.env.DISCORD_ENABLED) return false;
|
||||
log.info({ msg }, 'sending discord message');
|
||||
const channel = client.channels.cache.get('1072490127324090418');
|
||||
// @ts-ignore - wrong
|
||||
const str = JSON.stringify(msg, null, 4);
|
||||
channel.send(`\`\`\`${str}\`\`\``);
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export type Discord = ReturnType<typeof createDiscord>;
|
||||
@@ -2,127 +2,72 @@ import { EventEmitter } from 'node:events';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
import type Log from 'bunyan';
|
||||
import type { Want, Offer } from '@prisma/client';
|
||||
import type { Want } from '@prisma/client';
|
||||
|
||||
import { Pg } from './pg';
|
||||
import { Rpc } from './rpc';
|
||||
import { wantDetails } from './utils';
|
||||
import routerAuth from './routers/router.auth';
|
||||
|
||||
export enum Events {
|
||||
WantsAdded = 'WantsAdded',
|
||||
Sync = 'Sync',
|
||||
Connection = 'Connection',
|
||||
|
||||
StartupSync = 'StartupSync',
|
||||
ListingsUpdated = 'ListingsUpdated',
|
||||
OffersUpdated = 'OffersUpdated',
|
||||
WantsUpdated = 'WantsUpdated',
|
||||
|
||||
DiscordMessage = 'DiscordMessage',
|
||||
Connection = 'Connection',
|
||||
}
|
||||
|
||||
export default function createEvents(log: Log, pg: Pg, discogs, offers, discord) {
|
||||
export default function createEvents(log: Log, pg: Pg, discogs, offers) {
|
||||
const events = new EventEmitter({ captureRejections: true });
|
||||
const connections = new Map<UUID, Rpc>();
|
||||
const connections = new Map();
|
||||
|
||||
events.on('error', function eventError(e) {
|
||||
log.error('event error', e);
|
||||
});
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const SYNC_EVERY_MS = 60 * 1000
|
||||
const RATE_LIMIT_WAIT_MS = (attempts) => attempts * 70 * 1000
|
||||
async function populateListings(want: Want, attempts = 1) {
|
||||
async function populateListings(want: Want) {
|
||||
try {
|
||||
const listings = await discogs.listings(want);
|
||||
await pg.wantSynced(want);
|
||||
return listings;
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
if (e.response.status === 429) {
|
||||
log.warn({ RATE_LIMIT_WAIT_MS: RATE_LIMIT_WAIT_MS(attempts), ...e.response.headers }, 'rate limited, trying again');
|
||||
|
||||
await setTimeout(RATE_LIMIT_WAIT_MS(attempts));
|
||||
if (attempts > MAX_ATTEMPTS) {
|
||||
log.error(wantDetails(want), 'failed to populate listings')
|
||||
}
|
||||
return populateListings(want, attempts++);
|
||||
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 found');
|
||||
await offers.generate();
|
||||
await notifyUser(want.userId);
|
||||
log.info({ listings: listings.length, ...wantDetails(want) }, 'listings added');
|
||||
events.emit(Events.ListingsUpdated);
|
||||
}
|
||||
|
||||
log.info({ wants: wants.length }, 'finished syncing');
|
||||
}
|
||||
|
||||
async function notifyUser(id: UUID) {
|
||||
const rpc = connections.get(id);
|
||||
if (!rpc) return null;
|
||||
rpc.pushUserWants();
|
||||
rpc.pushUserOffers();
|
||||
};
|
||||
|
||||
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.Sync, async function sync() {
|
||||
try {
|
||||
const wants = await pg.wantsToSync();
|
||||
log.info({ event: Events.Sync, wants: wants.length });
|
||||
await updateWantListings(wants);
|
||||
|
||||
if (wants.length === 0) {
|
||||
log.info('all listings up to date');
|
||||
await setTimeout(10000)
|
||||
// forever
|
||||
events.emit(Events.Sync);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e, 'sync error')
|
||||
}
|
||||
|
||||
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.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({ rpc, id }) {
|
||||
log.debug('new rpc connection', { id, rpc });
|
||||
connections.set(id, rpc);
|
||||
events.on(Events.Connection, async function onConnection({ ws, id }) {
|
||||
connections.set(id, ws);
|
||||
})
|
||||
|
||||
events.on(Events.DiscordMessage, async function onDiscordMessage(msg) {
|
||||
discord.message(msg);
|
||||
})
|
||||
|
||||
// emitted by listen/notify
|
||||
// disabled for now
|
||||
// events.on(Events.OffersUpdated, async function onOffersUpdated(offer: Offer) {
|
||||
// const rpc = connections.get(offer.userId);
|
||||
// if (!rpc) return null;
|
||||
// log.debug({ event: Events.OffersUpdated, rpc, offer });
|
||||
// return rpc.pushUserOffers();
|
||||
// });
|
||||
|
||||
// events.on(Events.WantsUpdated, async function onWantsUpdated(want: Want) {
|
||||
// const rpc = connections.get(want.userId);
|
||||
// if (!rpc) return null;
|
||||
// log.debug({ event: Events.WantsUpdated, rpc, want });
|
||||
// return rpc.pushUserWants();
|
||||
// });
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import createSubscriber from 'pg-listen'
|
||||
import type Log from 'bunyan';
|
||||
|
||||
import { Events } from './events';
|
||||
import { Pg } from './pg';
|
||||
|
||||
export default function createNotify(log: Log, pg: Pg, events) {
|
||||
const OFFERS_CHANNEL = 'offers.events';
|
||||
const USERS_CHANNEL = 'users.events';
|
||||
const LISTINGS_CHANNEL = 'listings.events';
|
||||
const WANTS_CHANNEL = 'wants.events';
|
||||
|
||||
const subscriber = createSubscriber({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
// pg has a hard limit of 8000B for the notification payload
|
||||
// so we only receive the ids of the updated record
|
||||
// and go fetch the whole thing to figure out who to notify
|
||||
|
||||
async function handleOffers(payload) {
|
||||
const { action, id } = payload;
|
||||
log.debug({ channel: OFFERS_CHANNEL, action, id });
|
||||
if (action === 'DELETE') return null;
|
||||
|
||||
// const offer = await pg.offerFindId(id);
|
||||
// events.emit(Events.OffersUpdated, offer);
|
||||
}
|
||||
|
||||
async function handleUsers(payload) {
|
||||
const { action, id } = payload;
|
||||
log.debug({ channel: USERS_CHANNEL, action, id });
|
||||
}
|
||||
|
||||
async function handleListings(payload) {
|
||||
const { action, id } = payload;
|
||||
log.debug({ channel: LISTINGS_CHANNEL, action, id });
|
||||
}
|
||||
|
||||
async function handleWants(payload) {
|
||||
const { action, id } = payload;
|
||||
// const want = await pg.offerFindId(id);
|
||||
log.debug({ channel: WANTS_CHANNEL, action, id });
|
||||
}
|
||||
|
||||
subscriber.notifications.on(OFFERS_CHANNEL, handleOffers);
|
||||
subscriber.notifications.on(USERS_CHANNEL, handleUsers);
|
||||
subscriber.notifications.on(LISTINGS_CHANNEL, handleListings);
|
||||
subscriber.notifications.on(WANTS_CHANNEL, handleWants);
|
||||
|
||||
async function listen() {
|
||||
await subscriber.connect();
|
||||
await Promise.all([
|
||||
subscriber.listenTo(OFFERS_CHANNEL),
|
||||
subscriber.listenTo(USERS_CHANNEL),
|
||||
subscriber.listenTo(LISTINGS_CHANNEL),
|
||||
subscriber.listenTo(WANTS_CHANNEL),
|
||||
]);
|
||||
}
|
||||
|
||||
return { listen };
|
||||
}
|
||||
@@ -51,7 +51,8 @@ export default function createOffers(log: Log, pg: Pg) {
|
||||
// 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.trace('generated offers', sorted);
|
||||
log.info(sorted);
|
||||
|
||||
const inserts = await pg.offersUpdate(sorted);
|
||||
|
||||
return inserts;
|
||||
@@ -63,20 +64,19 @@ export default function createOffers(log: Log, pg: Pg) {
|
||||
|
||||
// check the user actually has this offer
|
||||
const offer = await pg.userOfferFind(ctx.state.user, id);
|
||||
return pg.offerStatusSet([offer.id], Status.Accepted);
|
||||
return pg.offerStatusSet(offer.id, Status.Accepted);
|
||||
}
|
||||
|
||||
async function statusSet(user: User, ids: UUID[], status: Status) {
|
||||
log.info({ status, ids }, 'setting offer status');
|
||||
async function statusSet(user: User, id: UUID, status: Status) {
|
||||
log.info({ status, id }, 'setting offer status');
|
||||
|
||||
// const offer = await pg.offerFindId(ids);
|
||||
const update = await pg.offerStatusSet(ids, status);
|
||||
const offer = await pg.offerFindId(id);
|
||||
const update = await pg.offerStatusSet(offer.id, status);
|
||||
|
||||
// forget about this for now
|
||||
// if (status === Status.Complete) {
|
||||
// log.info({ id, status }, 'completed offer, removing want...');
|
||||
// await pg.wantRm(offer.wantId);
|
||||
// }
|
||||
if (status === Status.Complete) {
|
||||
log.info({ id, status }, 'completed offer, removing want...');
|
||||
await pg.wantRm(offer.wantId);
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
@@ -113,14 +113,12 @@ export default function createOffers(log: Log, pg: Pg) {
|
||||
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 offers = sortBy(g, sortOffers);
|
||||
const status = offers[0].status;
|
||||
|
||||
// now actually sort them by price
|
||||
offers.sort((a, b) => a.total - b.total);
|
||||
|
||||
const details = offers[0].want.data;
|
||||
|
||||
return {
|
||||
@@ -136,8 +134,7 @@ export default function createOffers(log: Log, pg: Pg) {
|
||||
async function samples() {
|
||||
const user = await pg.userFindEmail('ntr@mnml.gg');
|
||||
const offerGroups = await userOffers(user);
|
||||
const i = Math.floor(Math.random() * offerGroups.length / 2);
|
||||
return offerGroups[i];
|
||||
return offerGroups.slice(0, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Status } from './offers';
|
||||
import { wantDetails } from './utils';
|
||||
|
||||
export interface UserCreateParams {
|
||||
email: string,
|
||||
@@ -22,12 +21,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
}
|
||||
|
||||
async function userWants(user: User) {
|
||||
const wants = await prisma.want.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { _count: { select: { offers: true }} },
|
||||
})
|
||||
|
||||
return _.sortBy(wants, w => `${w.data.artists_sort} - ${w.data.title}`);
|
||||
return prisma.want.findMany({ where: { userId: user.id }});
|
||||
}
|
||||
|
||||
async function userOffers(user: User) {
|
||||
@@ -40,7 +34,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
want: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ updatedAt: 'asc' },
|
||||
{ total: 'asc' },
|
||||
{ listingId: 'asc' },
|
||||
],
|
||||
@@ -100,12 +93,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
});
|
||||
}
|
||||
|
||||
async function wantFindId(id: UUID) {
|
||||
return prisma.want.findFirstOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async function wantList(user: User) {
|
||||
return prisma.want.findMany({
|
||||
where: { userId: user.id },
|
||||
@@ -113,12 +100,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
}
|
||||
|
||||
async function wantsToSync() {
|
||||
return prisma.want.findMany({
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
where: {
|
||||
updatedAt: { lte: new Date(Date.now() - 1000 * 60 * 15) }
|
||||
}
|
||||
});
|
||||
return prisma.want.findMany({});
|
||||
}
|
||||
|
||||
async function wantRm(id: UUID) {
|
||||
@@ -139,21 +121,14 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
});
|
||||
}
|
||||
|
||||
async function wantSynced(want: Want) {
|
||||
return prisma.want.update({
|
||||
where: { id: want.id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function listingRmUnlisted(want: Want, currentListingIds: DiscogsListing[]) {
|
||||
return prisma.$transaction(async (prisma) => {
|
||||
log.debug({ currentListingIds });
|
||||
|
||||
const protectedOfferStatuses = [Status.Confirmed, Status.Complete];
|
||||
|
||||
// delete all the offers where we don't want to keep them
|
||||
// for historical reasons
|
||||
// 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,
|
||||
@@ -166,7 +141,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
}
|
||||
});
|
||||
|
||||
if (offersRemoved > 0) log.info({ offersRemoved, ...wantDetails(want) }, 'pending offers removed');
|
||||
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
|
||||
@@ -175,26 +150,25 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
wantId: want.id,
|
||||
status: {
|
||||
in: protectedOfferStatuses,
|
||||
},
|
||||
}
|
||||
},
|
||||
select: {
|
||||
listingId: true,
|
||||
},
|
||||
}
|
||||
})).map(r => r.listingId);
|
||||
const removalIds = currentListingIds.filter(id => confirmedListingIds.includes(id));
|
||||
|
||||
if (removalIds.length) log.info({ removalIds });
|
||||
const removalIds = _.without(currentListingIds, ...confirmedListingIds);
|
||||
|
||||
// finally we delete listings that aren't related
|
||||
// to a historical offer
|
||||
const { count } = await prisma.listing.deleteMany({
|
||||
where: {
|
||||
id: { in: removalIds },
|
||||
},
|
||||
wantId: want.id,
|
||||
id: {
|
||||
notIn: currentListingIds,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (count) log.info({ count }, 'second stage open offers removed');
|
||||
|
||||
return count;
|
||||
});
|
||||
}
|
||||
@@ -247,21 +221,17 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
// but for ease of abstraction we get the listings themselves
|
||||
// grouped by seller
|
||||
const candidates = await prisma.$queryRaw`
|
||||
WITH groups AS (
|
||||
select
|
||||
seller, count(distinct("wantId")), array_agg(id) as listings
|
||||
from
|
||||
listings
|
||||
where
|
||||
seller not in (3709203)
|
||||
group by
|
||||
seller
|
||||
order by
|
||||
count desc
|
||||
)
|
||||
select *
|
||||
from groups
|
||||
where count >= 5;
|
||||
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({
|
||||
@@ -289,9 +259,9 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
);
|
||||
}
|
||||
|
||||
async function offerStatusSet(ids: UUID[], status: Status) {
|
||||
return prisma.offer.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
async function offerStatusSet(id: UUID, status: Status) {
|
||||
return prisma.offer.update({
|
||||
where: { id: id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
@@ -318,7 +288,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
|
||||
wantCreate,
|
||||
wantList,
|
||||
wantsToSync,
|
||||
wantSynced,
|
||||
wantRm,
|
||||
|
||||
listingUpsert,
|
||||
|
||||
@@ -4,9 +4,8 @@ import { Strategy as LocalStrategy } from 'passport-local';
|
||||
import Router from '@koa/router'
|
||||
|
||||
import type { Pg } from './../pg';
|
||||
import { Events } from './../events';
|
||||
|
||||
export default function authRouter(log, pg: Pg, events) {
|
||||
export default function authRouter(log, pg: Pg) {
|
||||
const passport = new KoaPassport();
|
||||
|
||||
async function verifyPassword(user, password) {
|
||||
@@ -70,7 +69,6 @@ export default function authRouter(log, pg: Pg, events) {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
events.emit(Events.DiscordMessage, { msg: 'user register', email: user.email });
|
||||
});
|
||||
|
||||
return { authRouter, passport };
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
import assert from 'assert/strict';
|
||||
|
||||
import type Log from 'bunyan';
|
||||
@@ -10,82 +12,6 @@ import type { Offers } from './offers';
|
||||
|
||||
import { Status } from './offers';
|
||||
import { isAdmin } from './utils';
|
||||
import { Events } from './events';
|
||||
|
||||
export type Rpc = ReturnType<typeof createRpc>;
|
||||
|
||||
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',
|
||||
AcpListingsSync = 'AcpListingsSync',
|
||||
|
||||
WantRm = 'WantRm',
|
||||
WantAdd = 'WantAdd',
|
||||
}
|
||||
|
||||
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[]];
|
||||
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
|
||||
|
||||
export type RpcMessage =
|
||||
PingMessage |
|
||||
PongMessage |
|
||||
WhoDisMessage |
|
||||
|
||||
UserMessage |
|
||||
|
||||
WantsMessage |
|
||||
|
||||
OffersMessage |
|
||||
OfferSamplesMessage |
|
||||
OfferStatusSetMessage |
|
||||
|
||||
AcpOfferStatusSetMessage |
|
||||
AcpOffersMessage |
|
||||
AcpListingsSyncMessage |
|
||||
|
||||
ErrorMessage;
|
||||
|
||||
|
||||
type OfferStatusSetParams = {
|
||||
ids: UUID[],
|
||||
status: Status,
|
||||
};
|
||||
|
||||
type WantRmParams = {
|
||||
id: UUID,
|
||||
};
|
||||
|
||||
type WantAddParams = {
|
||||
releaseId: number,
|
||||
};
|
||||
|
||||
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
|
||||
if (RpcMessageType[msgType]) {
|
||||
@@ -95,7 +21,7 @@ function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
|
||||
}
|
||||
|
||||
// rpc is created per connection from the api
|
||||
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, events, user: User | null) {
|
||||
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, user: User | null) {
|
||||
async function ping(): Promise<PongMessage> {
|
||||
return [RpcMessageType.Pong, []];
|
||||
}
|
||||
@@ -109,15 +35,13 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
}
|
||||
|
||||
async function offerStatusSet(params: OfferStatusSetParams): Promise<OffersMessage> {
|
||||
const { ids, status } = params;
|
||||
assert(ids, 'no ids supplied');
|
||||
const { id, status } = params;
|
||||
assert(id, 'no id supplied');
|
||||
assert(Status[status], 'unknown status');
|
||||
|
||||
const offer = await offers.statusSet(user, ids, status);
|
||||
const offer = await offers.statusSet(user, id, status);
|
||||
const userOffers = await offers.userOffers(user);
|
||||
|
||||
events.emit(Events.DiscordMessage, { msg: 'user offer event', user: user.email, offer })
|
||||
|
||||
return [RpcMessageType.Offers, userOffers]
|
||||
}
|
||||
|
||||
@@ -131,18 +55,6 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
return [RpcMessageType.Wants, wants];
|
||||
}
|
||||
|
||||
async function wantAdd(params: WantAddParams): Promise<WantsMessage> {
|
||||
const { releaseId } = params;
|
||||
assert(releaseId, 'no id supplied');
|
||||
|
||||
const release = await discogs.release(releaseId);
|
||||
const want = await pg.wantCreate(release, user);
|
||||
events.emit(Events.WantsAdded, [want]);
|
||||
|
||||
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');
|
||||
@@ -159,12 +71,6 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
return [RpcMessageType.AcpOffers, await offers.acpOffers()];
|
||||
}
|
||||
|
||||
async function acpListingsSync(): Promise<AcpListingsSyncMessage> {
|
||||
assert(isAdmin(user), 'not authorised');
|
||||
events.emit(Events.Sync);
|
||||
return [RpcMessageType.AcpListingsSync, []];
|
||||
}
|
||||
|
||||
async function receive(message: any): Promise<RpcMessage> {
|
||||
const [msgType, params] = message;
|
||||
|
||||
@@ -190,15 +96,11 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
|
||||
case RpcMessageType.WantRm:
|
||||
return wantRm(params);
|
||||
case RpcMessageType.WantAdd:
|
||||
return wantAdd(params);
|
||||
|
||||
case RpcMessageType.AcpOfferStatusSet:
|
||||
return acpOfferStatusSet(params);
|
||||
case RpcMessageType.AcpOffers:
|
||||
return acpOffers();
|
||||
case RpcMessageType.AcpListingsSync:
|
||||
return acpListingsSync();
|
||||
|
||||
default:
|
||||
throw new Error(`Message type ${msgType} is known but unhandled`);
|
||||
@@ -211,12 +113,9 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
}
|
||||
|
||||
async function anonConnection() {
|
||||
|
||||
}
|
||||
|
||||
async function adminConnection() {
|
||||
// don't need it, requested on route
|
||||
// send(await acpOffers());
|
||||
const samples = await offers.samples();
|
||||
const sampleMsg: OfferSamplesMessage = [RpcMessageType.OfferSamples, samples];
|
||||
send(sampleMsg);
|
||||
}
|
||||
|
||||
async function onConnection() {
|
||||
@@ -225,10 +124,9 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
const userMsg: UserMessage = [RpcMessageType.User, user];
|
||||
send(userMsg);
|
||||
|
||||
await pushUserOffers();
|
||||
await pushUserWants();
|
||||
|
||||
if (isAdmin(user)) return adminConnection();
|
||||
const wants = await pg.userWants(user);
|
||||
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
|
||||
send(wantsMsg);
|
||||
}
|
||||
|
||||
function send(msg: RpcMessage) {
|
||||
@@ -239,20 +137,74 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
|
||||
))
|
||||
}
|
||||
|
||||
async function pushUserOffers() {
|
||||
send(await userOffers());
|
||||
}
|
||||
|
||||
async function pushUserWants() {
|
||||
send(await userWants());
|
||||
}
|
||||
|
||||
return {
|
||||
receive,
|
||||
send,
|
||||
onConnection,
|
||||
|
||||
pushUserOffers,
|
||||
pushUserWants,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -1,30 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
import createApi from './api';
|
||||
import createDiscogs from './discogs';
|
||||
import createEvents from './events';
|
||||
import createLog from './log';
|
||||
import createNotify from './notify';
|
||||
import createOffers from './offers';
|
||||
import createLog from './log';
|
||||
import createApi from './api';
|
||||
import createEvents from './events';
|
||||
import createPg from './pg';
|
||||
import createDiscord from './discord';
|
||||
|
||||
export default async function createServices(log) {
|
||||
export default function creates(log) {
|
||||
const prisma = new PrismaClient();
|
||||
const discord = createDiscord(log);
|
||||
const pg = createPg(log, prisma);
|
||||
const discogs = await createDiscogs(log, pg);
|
||||
const discogs = createDiscogs(log, pg);
|
||||
const offers = createOffers(log, pg);
|
||||
const events = createEvents(log, pg, discogs, offers, discord);
|
||||
const notify = createNotify(log, pg, events);
|
||||
const events = createEvents(log, pg, discogs, offers);
|
||||
const api = createApi(log, pg, discogs, offers, events);
|
||||
|
||||
return {
|
||||
api,
|
||||
discogs,
|
||||
discord,
|
||||
events,
|
||||
notify,
|
||||
pg,
|
||||
prisma,
|
||||
};
|
||||
|
||||
@@ -17,8 +17,6 @@ export function logAxiosError(e) {
|
||||
export function wantDetails(want: Want) {
|
||||
const data = want.data as any;
|
||||
|
||||
if (!data) console.warn(want);
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
release: data.id,
|
||||
|
||||
@@ -8,27 +8,23 @@ module.exports = {
|
||||
"build/"
|
||||
],
|
||||
rules: {
|
||||
'arrow-body-style': [0],
|
||||
|
||||
'max-len': ['error', 120],
|
||||
|
||||
'no-console': [0],
|
||||
'no-multi-spaces': [0],
|
||||
// prevents stupid complaints a la
|
||||
// (req) {
|
||||
// req.something = x;
|
||||
// }
|
||||
'no-param-reassign': [2, { props: false }],
|
||||
'no-return-assign': 0,
|
||||
|
||||
'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'],
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# build step
|
||||
FROM node:16 AS build-env
|
||||
WORKDIR /opt/crates/client
|
||||
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
# static server
|
||||
FROM nginx
|
||||
WORKDIR /srv/app
|
||||
COPY --from=build-env /opt/crates/client/build /srv/app
|
||||
COPY ./client.nginx.conf /etc/nginx/nginx.conf
|
||||
@@ -1,8 +0,0 @@
|
||||
# build step
|
||||
FROM node:16 AS build-env
|
||||
WORKDIR /opt/crates/client
|
||||
|
||||
COPY . ./
|
||||
RUN npm i
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,49 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
|
||||
autoindex off;
|
||||
|
||||
root /srv/app;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/geo+json
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rdf+xml
|
||||
application/rss+xml
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/eot
|
||||
font/otf
|
||||
font/ttf
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
error_page 404 /;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.8.5",
|
||||
"izitoast": "^1.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"logrocket": "^3.0.1",
|
||||
"preact": "^10.10.0",
|
||||
@@ -22,7 +21,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.18.6",
|
||||
"@types/lodash": "^4.17.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"eslint": "^8.20.0",
|
||||
@@ -3010,13 +3008,6 @@
|
||||
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz",
|
||||
"integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz",
|
||||
@@ -10853,11 +10844,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/izitoast": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
|
||||
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.8.5",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
|
||||
@@ -23445,12 +23431,6 @@
|
||||
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.17.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz",
|
||||
"integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz",
|
||||
@@ -29586,11 +29566,6 @@
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"izitoast": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
|
||||
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
|
||||
},
|
||||
"jake": {
|
||||
"version": "10.8.5",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
"scripts": {
|
||||
"build": "preact build --no-prerender",
|
||||
"start": "preact --no-clear watch",
|
||||
"lint": "eslint --fix src"
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.8.5",
|
||||
"izitoast": "^1.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"logrocket": "^3.0.1",
|
||||
"preact": "^10.10.0",
|
||||
@@ -22,7 +21,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.18.6",
|
||||
"@types/lodash": "^4.17.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"eslint": "^8.20.0",
|
||||
|
||||
@@ -9,24 +9,16 @@ import store, { wsState } from './store'
|
||||
|
||||
import Nav from './components/nav';
|
||||
|
||||
import Main from './routes/main';
|
||||
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 === 'crates.ntwl.xyz') LogRocket.init('yh0dy3/mcrates');
|
||||
if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates');
|
||||
|
||||
|
||||
// ██ ██ ███████ █████ ██████ ███████ ███████ ██████ ██████ █████ ██████ ██ ██
|
||||
// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
||||
// ██ █ ██ █████ ███████ ██████ █████ ███████ ██ ██ ██████ ███████ ██ █████
|
||||
// ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
||||
// ███ ███ ███████ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ██ ██ ██████ ██ ██
|
||||
|
||||
|
||||
const Mcrates = () => {
|
||||
const Main = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// i have no idea why this is needed,
|
||||
@@ -41,32 +33,30 @@ const Mcrates = () => {
|
||||
const { ws } = store.getState();
|
||||
if (!ws) return null;
|
||||
if (e.url === '/acp') return ws.sendAcpOffers();
|
||||
if (e.url === '/') return ws.sendOffers();
|
||||
if (e.url === '/list') return ws.sendWants();
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="mcrates">
|
||||
<main id="app">
|
||||
<Nav />
|
||||
<Router onChange={routeChange}>
|
||||
<Route path="/" component={Main} />
|
||||
<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} />*/}
|
||||
<Route path="/info" component={Info} />
|
||||
|
||||
{/* ACP */}
|
||||
<Route path="/acp" component={Acp} />
|
||||
</Router>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Mcrates />
|
||||
<Main />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 626 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { h } from 'preact';
|
||||
import { ExternalLink, Check } from 'lucide-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
|
||||
@@ -11,21 +23,21 @@ export default function Offer(offer) {
|
||||
: 'Accept';
|
||||
|
||||
const negBtn = accepted
|
||||
? <button disabled={true} class='neg'>Cancel</button>
|
||||
: <button disabled={true} class='neg'>Decline</button>;
|
||||
? <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" rel="noreferrer"><ExternalLink /></a></h3>
|
||||
<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 disabled={true} class={classes}>{btnText}</button>
|
||||
<button onClick={() => setStatus('Accepted')} disabled={accepted} class={classes} >{btnText}</button>
|
||||
{negBtn}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
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;
|
||||
|
||||
@@ -12,13 +14,10 @@ function Want(want) {
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
|
||||
function onClick() {
|
||||
if (confirm) {
|
||||
setConfirm(false);
|
||||
return ws.sendWantRm(want.id);
|
||||
}
|
||||
if (confirm) return ws.sendWantRm(want.id);
|
||||
setTimeout(() => setConfirm(false), 2500);
|
||||
return setConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const text = confirm
|
||||
? 'confirm'
|
||||
@@ -27,12 +26,12 @@ function Want(want) {
|
||||
return (
|
||||
<article key={r.id}>
|
||||
<div>
|
||||
<h3>{r.artists_sort} - {r.title} <a href={r.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3>
|
||||
<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} - {r.formats[0].descriptions.join(' ')}</div>
|
||||
<div class='alt'>{want._count.offers} offers</div>
|
||||
<div class='alt'>{r.year}</div>
|
||||
<div class='alt'>{r.formats[0].descriptions.join(' ')}</div>
|
||||
</div>
|
||||
<button onClick={onClick} >{text}</button>
|
||||
</article>
|
||||
@@ -15,8 +15,8 @@ declare module 'axios' {
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true
|
||||
baseURL: '/api',
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
function onSuccess(response) {
|
||||
@@ -32,18 +32,34 @@ function onError(error) {
|
||||
api.interceptors.response.use(onSuccess, onError);
|
||||
|
||||
const actions = {
|
||||
// TODO move to rpc
|
||||
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 });
|
||||
},
|
||||
|
||||
// --------------------------------
|
||||
// require as http to set cookies
|
||||
|
||||
getLogin: async function getLogin() {
|
||||
return api.get('/auth/login')
|
||||
},
|
||||
@@ -66,6 +82,14 @@ const actions = {
|
||||
password,
|
||||
});
|
||||
},
|
||||
|
||||
postOffers: async function postOffers(listingIds, shipping, baseShipping) {
|
||||
return api.post('/offers', {
|
||||
listingIds,
|
||||
shipping,
|
||||
baseShipping,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default actions;
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'izitoast/dist/css/iziToast.min.css';
|
||||
import './style/index.less';
|
||||
|
||||
import App from './app';
|
||||
|
||||
export default App;
|
||||
@@ -34,11 +34,11 @@ function Item({ item }) {
|
||||
|
||||
return (
|
||||
<div key={item.id} class={bold}>
|
||||
<a href={item.listing.data.uri} target="_blank" rel="noreferrer" >
|
||||
<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} - {item.condition}</h5>
|
||||
<h5 class='alt-text'>{item.total}</h5>
|
||||
<h5>{item.status}</h5>
|
||||
<label>Set Status:</label>
|
||||
<select onChange={onChange}>
|
||||
@@ -53,7 +53,7 @@ function SellerGroup(group) {
|
||||
|
||||
return (
|
||||
<div class="bundle">
|
||||
<section>
|
||||
<section>
|
||||
<h2>{group.username} - {group.seller}</h2>
|
||||
<h3>{ready} / {group.offers.length} offers</h3>
|
||||
<h4>Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}</h4>
|
||||
@@ -70,18 +70,21 @@ function Home() {
|
||||
const offers = useSelector((state: State) => state.acpOffers);
|
||||
const ws = useSelector((state: State) => state.ws);
|
||||
|
||||
function sync() {
|
||||
ws.sendAcpListingsSync();
|
||||
// useEffect(() => {
|
||||
// ws.sendAcpOffers();
|
||||
// }, [offers, ws]);
|
||||
|
||||
if (!offers.length) {
|
||||
return (
|
||||
<div class={'offers'}>
|
||||
loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class='acp'>
|
||||
<button onClick={sync}> sync listings </button>
|
||||
<button onClick={sync}> generate offers </button>
|
||||
<hr />
|
||||
<div class='bundles'>
|
||||
{offers.map(SellerGroup)}
|
||||
</div>
|
||||
<div class='bundles'>
|
||||
{offers.map(SellerGroup)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ const Info = () => {
|
||||
<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> (this can take a few minutes time!) </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>
|
||||
|
||||
@@ -4,11 +4,16 @@ import { useSelector } from 'react-redux'
|
||||
import { route } from 'preact-router';
|
||||
|
||||
import { State } from './../store'
|
||||
import * as toast from './../toast';
|
||||
|
||||
import Want from './../components/want';
|
||||
|
||||
const List = () => {
|
||||
interface Props {
|
||||
user: any,
|
||||
wants: any[],
|
||||
getUserOffers: (userId: string) => void,
|
||||
}
|
||||
|
||||
const List = ({ user }: Props) => {
|
||||
const wants = useSelector((state: State) => state.wants);
|
||||
|
||||
if (!wants) {
|
||||
@@ -16,7 +21,6 @@ const List = () => {
|
||||
}
|
||||
|
||||
if (!wants.length) {
|
||||
toast.info('List Empty', 'You have no items in your list, add some with search.');
|
||||
route('/search');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,19 @@ 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('');
|
||||
|
||||
function gotoRegister() {
|
||||
return route('/register');
|
||||
}
|
||||
|
||||
async function postLogin(email, password) {
|
||||
async function postLogin() {
|
||||
const [error, user] = await http.postLogin(email, password);
|
||||
if (error) return setError(error);
|
||||
route('/');
|
||||
@@ -27,23 +27,17 @@ const Login = ({ mode }) => {
|
||||
ws.connect();
|
||||
}
|
||||
|
||||
async function postRegister(email, password, confirm) {
|
||||
if (password !== confirm) return setError('passwords do not match');
|
||||
|
||||
async function postRegister() {
|
||||
const [error, user] = await http.postRegister(email, password);
|
||||
if (error) return setError(error);
|
||||
route('/search');
|
||||
dispatch(userState.actions.set(user));
|
||||
// reconnect ws to get logged in methods
|
||||
ws.connect();
|
||||
}
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
console.log(e.target.email, e.target.password, e.target.confirm);
|
||||
if (mode === 'login') return postLogin(e.target.email.value, e.target.password.value);
|
||||
if (mode === 'register') return postRegister(e.target.email.value, e.target.password.value, e.target.confirm.value);
|
||||
if (mode === 'login') return postLogin()
|
||||
if (mode === 'register') return postRegister();
|
||||
}
|
||||
|
||||
const emailRef = useRef<HTMLInputElement>()
|
||||
@@ -59,21 +53,21 @@ const Login = ({ mode }) => {
|
||||
if (mode === 'register') return (
|
||||
<div class="login">
|
||||
{errorEl}
|
||||
<form action="" onSubmit={handleSubmit}>
|
||||
<form action="">
|
||||
<div class="item">
|
||||
<label for="email">email</label>
|
||||
<input type="email" name="email" id="email" ref={emailRef} required />
|
||||
<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" required />
|
||||
<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="confirm" id="confirm" required />
|
||||
<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" />
|
||||
<input type="submit" value="register" onClick={handleSubmit} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -83,33 +77,38 @@ const Login = ({ mode }) => {
|
||||
return (
|
||||
<div class="login">
|
||||
{errorEl}
|
||||
<form action="" onSubmit={handleSubmit}>
|
||||
<form action="">
|
||||
<div class="item">
|
||||
<label for="email">email</label>
|
||||
<input type="email" name="email" id="email" ref={emailRef} />
|
||||
<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" />
|
||||
<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" />
|
||||
<input type="submit" value="login" onClick={handleSubmit} />
|
||||
</div>
|
||||
</form>
|
||||
<button onClick={gotoRegister}> create account </button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function LoginPage({ mode }) {
|
||||
const offers = useSelector((state: State) => state.offers);
|
||||
|
||||
return (
|
||||
<div class="landing">
|
||||
<Login mode={mode} />
|
||||
<Swirl />
|
||||
<div class='words'>
|
||||
<p>record importing service</p>
|
||||
<p>we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl</p>
|
||||
<p>then we get together and play them</p>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { route } from 'preact-router';
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { State } from './../store';
|
||||
import * as toast from './../toast';
|
||||
|
||||
import OfferGroup from './../components/offer.group';
|
||||
|
||||
const GroupNav = (want) => {
|
||||
if (!want._count.offers) return null
|
||||
const href = `#${want.release}`;
|
||||
|
||||
return (
|
||||
<div class=''>
|
||||
<a href={href}>{want.data.artists_sort} - {want.data.title}</a>
|
||||
<span>{want._count.offers}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function Home() {
|
||||
const offers = useSelector((state: State) => state.offers);
|
||||
const wants = useSelector((state: State) => state.wants);
|
||||
|
||||
if (offers && offers.length === 0) {
|
||||
toast.info('no offers', 'You have no offers yet, search for some more items.');
|
||||
route('/search');
|
||||
}
|
||||
|
||||
if (!offers || !wants) return <div>loading...</div>;
|
||||
|
||||
return (
|
||||
<main >
|
||||
<nav>
|
||||
<div class='header'>
|
||||
<span>Record</span>
|
||||
<span>Offers</span>
|
||||
</div>
|
||||
{wants.map(GroupNav)}
|
||||
</nav>
|
||||
<div class={'offers'}>
|
||||
{offers.map(OfferGroup)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -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;
|
||||
@@ -1,31 +1,28 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
import { ExternalLink, Check } from 'react-feather';
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { State } from './../store';
|
||||
import { info, error as toastError } from './../toast';
|
||||
|
||||
import http from '../http';
|
||||
|
||||
function Result(r, wants, ws) {
|
||||
function Result(r) {
|
||||
const [added, setAdded] = useState(false)
|
||||
|
||||
async function onClick() {
|
||||
ws.sendWantAdd(r.id);
|
||||
const [error, want] = await http.postWant(r.id);
|
||||
if (error) return false;
|
||||
setAdded(true);
|
||||
}
|
||||
|
||||
// releaseIds are bigints on server
|
||||
const wanted = wants.some(w => BigInt(w.release) === BigInt(r.id));
|
||||
|
||||
const classes = `${wanted ? 'added' : ''}`;
|
||||
const btnText = wanted
|
||||
const classes = `${added ? 'added' : ''}`;
|
||||
const btnText = added
|
||||
? <Check />
|
||||
: '+';
|
||||
|
||||
return (
|
||||
<article key={r.id}>
|
||||
<button onClick={onClick} disabled={wanted} class={classes} >{btnText}</button>
|
||||
<button onClick={onClick} disabled={added} class={classes} >{btnText}</button>
|
||||
<div>
|
||||
<h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank" rel="noreferrer"><ExternalLink /></a></h3>
|
||||
<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>
|
||||
@@ -37,46 +34,34 @@ function Result(r, wants, ws) {
|
||||
|
||||
const User = () => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [title, setTitle] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
const wants = useSelector((state: State) => state.wants);
|
||||
const ws = useSelector((state: State) => state.ws);
|
||||
const [infoText, setInfoText] = useState('');
|
||||
|
||||
async function fetchResults() {
|
||||
const [error, results] = await http.postWantSearch(query);
|
||||
|
||||
if (error) {
|
||||
setResults([]);
|
||||
toastError('Error', error);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
setResults([]);
|
||||
return info('No Results', 'No results returned from search');
|
||||
return setInfoText('No results');
|
||||
}
|
||||
|
||||
setInfoText('');
|
||||
setResults(results);
|
||||
}
|
||||
|
||||
async function submitWantlist() {
|
||||
const [error, wants] = await http.postDiscogs(username);
|
||||
|
||||
if (error) {
|
||||
setResults([]);
|
||||
toastError('Error', error);
|
||||
}
|
||||
|
||||
if (wants.length === 0) {
|
||||
setResults([]);
|
||||
return info('Success', 'No new additions');
|
||||
return setInfoText('No new additions');
|
||||
}
|
||||
|
||||
info('Success', `Added ${wants.length} items to your list.`);
|
||||
setInfoText(`Added ${wants.length} items to your list.`);
|
||||
}
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>();
|
||||
const resultsRef = useRef<HTMLDivElement>();
|
||||
const searchRef = useRef<HTMLInputElement>()
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current.focus()
|
||||
@@ -93,7 +78,11 @@ const User = () => {
|
||||
}
|
||||
|
||||
const topEl = results.length
|
||||
? <button onClick={() => resultsRef.current.scrollTo(0, 0)}> top </button>
|
||||
? <button onClick={() => window.scrollTo(0, 0)}> top </button>
|
||||
: null;
|
||||
|
||||
const infoEl = infoText
|
||||
? <article><div></div><div>{infoText}</div></article>
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -115,8 +104,9 @@ const User = () => {
|
||||
<hr />
|
||||
<p>Data provided by Discogs.</p>
|
||||
</div>
|
||||
<div class='results' ref={resultsRef}>
|
||||
{results.map(r => Result(r, wants, ws))}
|
||||
<div class='results'>
|
||||
{infoEl}
|
||||
{results.map(Result)}
|
||||
{topEl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,58 +2,57 @@ 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
|
||||
},
|
||||
name: 'user',
|
||||
initialState: null,
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
});
|
||||
|
||||
export const offersState = createSlice({
|
||||
name: 'offers',
|
||||
initialState: null,
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
name: 'offers',
|
||||
initialState: [],
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
});
|
||||
|
||||
export const acpOffersState = createSlice({
|
||||
name: 'acpOffers',
|
||||
initialState: [],
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
name: 'acpOffers',
|
||||
initialState: [],
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
});
|
||||
|
||||
export const wantsState = createSlice({
|
||||
name: 'wants',
|
||||
initialState: null,
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
name: 'wants',
|
||||
initialState: null,
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
});
|
||||
|
||||
export const wsState = createSlice({
|
||||
name: 'ws',
|
||||
initialState: null,
|
||||
reducers: {
|
||||
set: (state, action) => action.payload
|
||||
},
|
||||
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,
|
||||
offers: offersState.reducer,
|
||||
wants: wantsState.reducer,
|
||||
ws: wsState.reducer,
|
||||
user: userState.reducer,
|
||||
|
||||
acpOffers: acpOffersState.reducer,
|
||||
acpOffers: acpOffersState.reducer,
|
||||
});
|
||||
|
||||
const store = createStore(
|
||||
reducer,
|
||||
// @ts-ignore -- devtools window exists on dev
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
|
||||
reducer,
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
|
||||
);
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Barlow';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_A8s52Hs.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Barlow';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_Ass52Hs.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Barlow';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_DMs5.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;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09DdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09ndVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09HdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ097dVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09LdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09PdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/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;
|
||||
}
|
||||
@@ -23,6 +23,32 @@ img {
|
||||
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);
|
||||
@@ -45,7 +71,7 @@ html {
|
||||
--medium-font-size: 1.777em;
|
||||
--large-font-size: 2.369em;
|
||||
--xlarge-font-size: 3.157em;
|
||||
--base-font-family: 'Barlow', Arial, Sans-Serif;
|
||||
--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);
|
||||
@@ -75,7 +101,6 @@ html {
|
||||
|
||||
--color-blue: #475977;
|
||||
--color-green: #476d4a;
|
||||
--color-red: #ae4442;
|
||||
|
||||
--color-accepted: var(--color-blue);
|
||||
--color-confirmed: var(--color-green);
|
||||
@@ -106,11 +131,6 @@ button, input, optgroup, select, textarea {
|
||||
line-height:var(--base-line-height);
|
||||
}
|
||||
|
||||
button, [type=submit] {
|
||||
padding: var(--spacing-half);
|
||||
background: var(--background-1);
|
||||
}
|
||||
|
||||
input:hover, textarea:hover {
|
||||
color:var(--base-font-color);
|
||||
border:var(--hover-border)
|
||||
@@ -136,7 +156,7 @@ button, [type=button], [type=reset], [type=submit] {
|
||||
|
||||
font-family: var(--secondary-font-family);
|
||||
font-size: var(--small-font-size);
|
||||
// font-weight: bold;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
|
||||
&:hover {
|
||||
@@ -174,6 +194,178 @@ 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);
|
||||
@@ -184,11 +376,166 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
.alt-text, .alt {
|
||||
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);
|
||||
// font-size: var(--small-font-size);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@import 'fonts.less';
|
||||
@import 'styles.less';
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
body {
|
||||
padding-top: calc(var(--spacing)/2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mcrates {
|
||||
padding: 0 var(--spacing-double) var(--spacing) var(--spacing-double);
|
||||
display: grid;
|
||||
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main main"
|
||||
"footer footer";
|
||||
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
}
|
||||
|
||||
.landing {
|
||||
display: grid;
|
||||
|
||||
overflow: scroll;
|
||||
|
||||
grid-template-columns: 1fr 2fr;
|
||||
grid-gap: calc(var(--spacing-double) * 2);
|
||||
grid-template-areas:
|
||||
"login swirl"
|
||||
"login words";
|
||||
|
||||
.login {
|
||||
grid-area: login;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.swirl {
|
||||
grid-area: swirl;
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.words {
|
||||
grid-area: words;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
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;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.words {
|
||||
grid-area: words;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.swirl {
|
||||
grid-area: swirl;
|
||||
height: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
article {
|
||||
background-color: var(--background-1);
|
||||
|
||||
button {
|
||||
border-color: var(--background-2);
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
padding-left: var(--spacing);
|
||||
grid-gap: var(--spacing);
|
||||
|
||||
article {
|
||||
display: grid;
|
||||
padding: var(--spacing);
|
||||
grid-template-columns: 1fr 4fr min-content;
|
||||
}
|
||||
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
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);
|
||||
overflow: scroll;
|
||||
|
||||
article {
|
||||
display: grid;
|
||||
|
||||
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 {
|
||||
margin-bottom: var(--spacing);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
header {
|
||||
grid-area: header;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr min-content;
|
||||
|
||||
border-bottom: var(--base-border);
|
||||
padding-bottom: var(--spacing-half);
|
||||
margin-bottom: var(--spacing-half);
|
||||
|
||||
a {
|
||||
font-size: var(--small-font-size);
|
||||
margin-right: var(--spacing);
|
||||
}
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
grid-area: main;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"nav content";
|
||||
|
||||
grid-template-columns:
|
||||
1fr 1fr;
|
||||
|
||||
column-gap: var(--spacing);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
nav {
|
||||
// duck scrollbars
|
||||
padding-right: var(--spacing-half);
|
||||
|
||||
grid-area: nav;
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr min-content min-content;
|
||||
|
||||
column-gap: var(--spacing-half);
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.offers {
|
||||
grid-area: content;
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.acp {
|
||||
overflow: scroll;
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
input[type='range']{ accent-color: var(--color-accepted); }
|
||||
|
||||
@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";
|
||||
}
|
||||
|
||||
#mcrates {
|
||||
padding: 0 var(--spacing) 0 var(--spacing);
|
||||
}
|
||||
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 3fr;
|
||||
grid-gap: var(--spacing-half);
|
||||
grid-template-areas:
|
||||
"nav"
|
||||
"content";
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:1200px) {
|
||||
img {
|
||||
width: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------
|
||||
// toasts
|
||||
|
||||
.iziToast {
|
||||
border-radius: 0;
|
||||
border-color: var(--base-border-color);
|
||||
|
||||
.iziToast-body {
|
||||
font-family: var(--base-font-family);
|
||||
font-size: var(--base-font-size);
|
||||
}
|
||||
|
||||
.iziToast-title {
|
||||
font-family: var(--secondary-font-family);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>crates - record import service</title>
|
||||
<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>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import toast from 'izitoast';
|
||||
|
||||
export function info(title, message) {
|
||||
toast.info({
|
||||
title,
|
||||
message: message || '',
|
||||
position: 'bottomCenter',
|
||||
color: '#1f2128',
|
||||
titleColor: '#F4F1D0',
|
||||
messageColor: '#F4F1D0',
|
||||
iconColor: '#F4F1D0',
|
||||
icon: '',
|
||||
theme: 'dark',
|
||||
});
|
||||
}
|
||||
|
||||
export function error(title, message) {
|
||||
toast.error({
|
||||
title,
|
||||
message: message || '',
|
||||
position: 'bottomCenter',
|
||||
color: '#ae4442',
|
||||
titleColor: '#F4F1D0',
|
||||
messageColor: '#F4F1D0',
|
||||
iconColor: '#F4F1D0',
|
||||
icon: '',
|
||||
theme: 'dark',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
import { wantsState, userState, offersState, acpOffersState } from './store'
|
||||
import * as toast from './toast';
|
||||
import { wantsState, wsState, userState, offersState, acpOffersState } from './store'
|
||||
|
||||
const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`;
|
||||
|
||||
@@ -29,18 +26,14 @@ export default function createSocket(dispatch) {
|
||||
// setTimeout(sendPing, 50);
|
||||
}
|
||||
|
||||
function sendOfferStatusSet(ids, status) {
|
||||
send(['OfferStatusSet', { ids, status }]);
|
||||
function sendOfferStatusSet(id, status) {
|
||||
send(['OfferStatusSet', { id, status }]);
|
||||
}
|
||||
|
||||
function sendWantRm(id) {
|
||||
send(['WantRm', { id }]);
|
||||
}
|
||||
|
||||
function sendWantAdd(releaseId) {
|
||||
send(['WantAdd', { releaseId }]);
|
||||
}
|
||||
|
||||
function sendOffers() {
|
||||
send(['Offers', {}]);
|
||||
}
|
||||
@@ -57,10 +50,6 @@ export default function createSocket(dispatch) {
|
||||
send(['AcpOfferStatusSet', { id, status }]);
|
||||
}
|
||||
|
||||
function sendAcpListingsSync() {
|
||||
send(['AcpListingsSync', {}]);
|
||||
}
|
||||
|
||||
let pongTimeout;
|
||||
function onPong() {
|
||||
pongTimeout = setTimeout(sendPing, 5000);
|
||||
@@ -77,13 +66,7 @@ export default function createSocket(dispatch) {
|
||||
dispatch(wantsState.actions.set(wants));
|
||||
}
|
||||
|
||||
const offersNotification = throttle((offers) => {
|
||||
if (!offers.length) return false;
|
||||
toast.info('Offers Updated', 'New offers available.')
|
||||
}, 60 * 1000);
|
||||
|
||||
function onOffers(offers) {
|
||||
offersNotification(offers);
|
||||
dispatch(offersState.actions.set(offers));
|
||||
}
|
||||
|
||||
@@ -101,6 +84,7 @@ export default function createSocket(dispatch) {
|
||||
Offers: onOffers,
|
||||
User: onUser,
|
||||
|
||||
OfferSamples: onOffers,
|
||||
AcpOffers: onAcpOffers,
|
||||
|
||||
Pong: onPong,
|
||||
@@ -127,8 +111,10 @@ export default function createSocket(dispatch) {
|
||||
// Connection opened
|
||||
function onOpen() {
|
||||
console.log('websocket connected', SOCKET_URL)
|
||||
toast.info('connected', 'connected to server');
|
||||
sendPing();
|
||||
sendAcpOffers();
|
||||
sendOffers();
|
||||
sendWants();
|
||||
}
|
||||
|
||||
function onError(event) {
|
||||
@@ -167,13 +153,10 @@ export default function createSocket(dispatch) {
|
||||
|
||||
sendOfferStatusSet,
|
||||
sendWantRm,
|
||||
sendWantAdd,
|
||||
|
||||
sendOffers,
|
||||
sendWants,
|
||||
|
||||
sendAcpOffers,
|
||||
sendAcpOfferStatusSet,
|
||||
sendAcpListingsSync,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"settings": {
|
||||
"LSP": {
|
||||
"LSP-typescript": {
|
||||
"enabled": false,
|
||||
},
|
||||
"Deno": {
|
||||
"enabled": true,
|
||||
"settings": {
|
||||
"deno.config": "./crates/deno.jsonc",
|
||||
"deno.unstable": true,
|
||||
"deno.importMap": "./import_map.json",
|
||||
"deno.suggest.imports.hosts": {
|
||||
"https://deno.land": true,
|
||||
"https://some-other-cdn.com": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"LSP-pyright":
|
||||
{
|
||||
"enabled": false
|
||||
},
|
||||
},
|
||||
},
|
||||
"folders":
|
||||
[
|
||||
{
|
||||
"path": ".",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# Fresh build directory
|
||||
_fresh/
|
||||
# npm dependencies
|
||||
node_modules/
|
||||
@@ -1,3 +0,0 @@
|
||||
- forms submitted should be validated using zod
|
||||
- packages should always be added to deno.jsonc as import maps
|
||||
- errors should be handled in the mw.error.ts and thrown as custom errors
|
||||
@@ -1,16 +0,0 @@
|
||||
# Fresh project
|
||||
|
||||
Your new Fresh project is ready to go. You can follow the Fresh "Getting
|
||||
Started" guide here: https://fresh.deno.dev/docs/getting-started
|
||||
|
||||
### Usage
|
||||
|
||||
Make sure to install Deno: https://deno.land/manual/getting_started/installation
|
||||
|
||||
Then start the project:
|
||||
|
||||
```
|
||||
deno task start
|
||||
```
|
||||
|
||||
This will watch the project directory and restart as necessary.
|
||||
@@ -1,80 +0,0 @@
|
||||
// Base custom error class
|
||||
export class AppError extends Error {
|
||||
status: number
|
||||
details?: Record<string, unknown>
|
||||
override name: string
|
||||
|
||||
constructor(message: string, status = 500, details?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
this.status = status
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
// Validation error
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message = "Validation failed", details?: Record<string, unknown>) {
|
||||
super(message, 400, details)
|
||||
this.name = "ValidationError"
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
export class AuthError extends AppError {
|
||||
constructor(message = "Authentication failed", status = 401) {
|
||||
super(message, status)
|
||||
this.name = "AuthError"
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCredentialsError extends AuthError {
|
||||
constructor(message = "Invalid credentials") {
|
||||
super(message)
|
||||
this.name = "InvalidCredentialsError"
|
||||
}
|
||||
}
|
||||
|
||||
export class NotLoggedInError extends AuthError {
|
||||
constructor(message = "Not logged in") {
|
||||
super(message)
|
||||
this.name = "NotLoggedInError"
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionExpiredError extends AuthError {
|
||||
constructor(message = "Session expired") {
|
||||
super(message)
|
||||
this.name = "SessionExpiredError"
|
||||
}
|
||||
}
|
||||
|
||||
// Resource errors
|
||||
export class ResourceError extends AppError {
|
||||
constructor(message: string, status = 404) {
|
||||
super(message, status)
|
||||
this.name = "ResourceError"
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceNotFoundError extends ResourceError {
|
||||
constructor(resource = "Resource") {
|
||||
super(`${resource} not found`)
|
||||
this.name = "ResourceNotFoundError"
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceExistsError extends ResourceError {
|
||||
constructor(resource = "Resource") {
|
||||
super(`${resource} already exists`, 409)
|
||||
this.name = "ResourceExistsError"
|
||||
}
|
||||
}
|
||||
|
||||
// Server error
|
||||
export class ServerError extends AppError {
|
||||
constructor(message = "An unexpected error occurred") {
|
||||
super(message, 500)
|
||||
this.name = "ServerError"
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { FreshContext } from "$fresh/server.ts"
|
||||
import { getCookies } from "$std/http/cookie.ts"
|
||||
import { CratesCtx } from 'api/mw.service.ts'
|
||||
|
||||
const PROTECTED = [
|
||||
"/",
|
||||
"/home",
|
||||
"/api/auth",
|
||||
]
|
||||
|
||||
// Open Deno KV database
|
||||
const kv = await Deno.openKv();
|
||||
|
||||
// Session prefix for KV
|
||||
const SESSION_PREFIX = "session:";
|
||||
|
||||
// Helper functions for session management
|
||||
export async function createSession(userId: string, expirationHours = 24): Promise<{sessionId: string, expiresAt: Date}> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + expirationHours);
|
||||
|
||||
await kv.set([SESSION_PREFIX, sessionId], {
|
||||
userId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
return { sessionId, expiresAt };
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await kv.delete([SESSION_PREFIX, sessionId]);
|
||||
}
|
||||
|
||||
export async function getSession(sessionId: string): Promise<{ userId: string, expiresAt: Date } | null> {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const sessionRes = await kv.get([SESSION_PREFIX, sessionId]);
|
||||
const session = sessionRes.value as { userId: string, expiresAt: string } | null;
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired
|
||||
if (new Date() > new Date(session.expiresAt)) {
|
||||
await deleteSession(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: session.userId,
|
||||
expiresAt: new Date(session.expiresAt)
|
||||
};
|
||||
}
|
||||
|
||||
export async function authMiddleware(req: Request, ctx: CratesCtx): Promise<Response> {
|
||||
const { pg } = ctx.state
|
||||
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname
|
||||
|
||||
// Check for authentication
|
||||
const cookies = getCookies(req.headers)
|
||||
const sessionId = cookies.session
|
||||
|
||||
// Allow public paths without authentication
|
||||
if (!PROTECTED.includes(path)) {
|
||||
return await ctx.next()
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
if (path.startsWith("/api/")) {
|
||||
return new Response(JSON.stringify({ error: "Not logged in" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect to login page for non-API routes
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: "/login" }
|
||||
})
|
||||
}
|
||||
|
||||
// Verify session using getSession helper
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
if (path.startsWith("/api/")) {
|
||||
return new Response(JSON.stringify({ error: "Invalid or expired session" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to login page for non-API routes
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: "/login" }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await pg.userFindId(session.userId)
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found")
|
||||
}
|
||||
|
||||
ctx.state.user = user
|
||||
return await ctx.next()
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { FreshContext } from "$fresh/server.ts"
|
||||
import { AppError } from "./errors.ts"
|
||||
|
||||
export async function errorMiddleware(req: Request, ctx: FreshContext): Promise<Response> {
|
||||
try {
|
||||
const response = await ctx.next()
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${req.method} ${new URL(req.url).pathname}:`, error)
|
||||
|
||||
// Check if it's a custom app error
|
||||
if (error instanceof AppError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error.message,
|
||||
...(error.details && { details: error.details })
|
||||
}),
|
||||
{
|
||||
status: error.status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Handle unexpected errors
|
||||
return new Response(
|
||||
JSON.stringify({ error: "An unexpected error occurred" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { User } from 'prisma/generated/index.ts'
|
||||
|
||||
import { Pg } from 'api/pg.ts'
|
||||
import { Offers } from 'api/offers.ts'
|
||||
import { createPg } from 'api/pg.ts'
|
||||
import createOffers from 'api/offers.ts'
|
||||
|
||||
export interface CratesState {
|
||||
user: User
|
||||
pg: Pg;
|
||||
offers: Offers
|
||||
}
|
||||
|
||||
export type CratesCtx = FreshContext<CratesState>
|
||||
|
||||
export async function serviceMiddleware(_req: Request, ctx: FreshContext): Promise<Response> {
|
||||
const pg = createPg(console)
|
||||
const offers = createOffers(pg)
|
||||
|
||||
ctx.state.pg = pg
|
||||
ctx.state.offers = offers
|
||||
|
||||
return await ctx.next()
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { assert } from "$std/assert/mod.ts";
|
||||
import { groupBy, sortBy, flatMap } from 'lodash'
|
||||
|
||||
import type { User } from 'prismaClient';
|
||||
import { Pg } from "api/pg.ts";
|
||||
import { UUID } from 'node:crypto'
|
||||
|
||||
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(pg: Pg) {
|
||||
async function generate() {
|
||||
const listings = flatMap(await pg.interestingListings());
|
||||
console.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
|
||||
|
||||
console.trace('generated offers', sorted);
|
||||
const inserts = await pg.offersUpdate(sorted);
|
||||
|
||||
return inserts;
|
||||
}
|
||||
|
||||
async function accept(user: User, id: UUID) {
|
||||
assert(id, 'no id supplied');
|
||||
|
||||
// check the user actually has this offer
|
||||
const offer = await pg.userOfferFind(user, id);
|
||||
return pg.offerStatusSet([offer.id], Status.Accepted);
|
||||
}
|
||||
|
||||
async function statusSet(user: User, ids: UUID[], status: Status) {
|
||||
console.info({ status, ids }, 'setting offer status');
|
||||
|
||||
// Validate the inputs
|
||||
assert(Array.isArray(ids), "ids must be an array");
|
||||
assert(ids.length > 0, "ids array cannot be empty");
|
||||
assert(ids.every(id => isUUID(id)), "All ids must be valid UUIDs");
|
||||
assert(Object.values(Status).includes(status), "Invalid status value");
|
||||
|
||||
// const offer = await pg.offerFindId(ids);
|
||||
const update = await pg.offerStatusSet(ids, status);
|
||||
|
||||
// forget about this for now
|
||||
// if (status === Status.Complete) {
|
||||
// console.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 => {
|
||||
// the offer status is just the most advanced status
|
||||
// based on sort
|
||||
const offers = sortBy(g, sortOffers);
|
||||
const status = offers[0].status;
|
||||
|
||||
// now actually sort them by price
|
||||
offers.sort((a, b) => a.total - b.total);
|
||||
|
||||
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);
|
||||
const i = Math.floor(Math.random() * offerGroups.length / 2);
|
||||
return offerGroups[i];
|
||||
}
|
||||
|
||||
return {
|
||||
generate,
|
||||
accept,
|
||||
statusSet,
|
||||
acpOffers,
|
||||
userOffers,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
export type Offers = ReturnType<typeof createOffers>;
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"
|
||||
import { load } from "$std/dotenv/mod.ts"
|
||||
import { PrismaClient } from "prismaClient"
|
||||
|
||||
// Load environment variables
|
||||
await load({ export: true })
|
||||
|
||||
// Create and export the Prisma client
|
||||
const DATABASE_URL = Deno.env.get("DATABASE_URL")
|
||||
console.log("DATABASE_URL", { DATABASE_URL })
|
||||
export const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export interface UserCreateParams {
|
||||
email: string
|
||||
password: string
|
||||
discogs?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPg(log: Console) {
|
||||
async function userFindId(id: string) {
|
||||
return prisma.user.findUnique({ where: { id } })
|
||||
}
|
||||
|
||||
async function userFindEmail(email: string) {
|
||||
return prisma.user.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
async function userWants(user: User) {
|
||||
const wants = await prisma.want.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { _count: { select: { offers: true } } },
|
||||
})
|
||||
|
||||
// Sort by artist and title
|
||||
return wants.sort((a, b) => {
|
||||
const aKey = `${a.data.artists_sort} - ${a.data.title}`
|
||||
const bKey = `${b.data.artists_sort} - ${b.data.title}`
|
||||
return aKey.localeCompare(bKey)
|
||||
})
|
||||
}
|
||||
|
||||
async function userOffers(user: User) {
|
||||
return prisma.offer.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: { not: "declined" } // Assuming Status.Declined is "declined"
|
||||
},
|
||||
include: {
|
||||
want: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ updatedAt: "asc" },
|
||||
{ total: "asc" },
|
||||
{ listingId: "asc" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function userCreate(params: UserCreateParams) {
|
||||
const password = await bcrypt.hash(params.password)
|
||||
|
||||
const data = {
|
||||
email: params.email,
|
||||
password,
|
||||
data: params.data || {},
|
||||
}
|
||||
|
||||
return prisma.user.create({
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
userFindId,
|
||||
userFindEmail,
|
||||
userCreate,
|
||||
userWants,
|
||||
userOffers,
|
||||
|
||||
// Add more functions as needed
|
||||
}
|
||||
}
|
||||
|
||||
// Export type Pg for type checking
|
||||
export type Pg = ReturnType<typeof createPg>
|
||||
@@ -1,6 +0,0 @@
|
||||
function Ok(headers) {
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Want, User } from "prismaClient";
|
||||
|
||||
export function logFetchError(e: Response, requestUrl: string, responseData: any) {
|
||||
console.warn({
|
||||
status: e.status,
|
||||
statusText: e.statusText,
|
||||
url: requestUrl,
|
||||
data: responseData,
|
||||
rateLimiting: {
|
||||
limit: e.headers.get('x-discogs-ratelimit'),
|
||||
remaining: e.headers.get('x-discogs-ratelimit-remaining'),
|
||||
used: e.headers.get('x-discogs-ratelimit-used'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function wantDetails(want: Want) {
|
||||
const data = want.data as any;
|
||||
|
||||
if (!data) console.warn(want);
|
||||
|
||||
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() {}
|
||||
@@ -1,89 +0,0 @@
|
||||
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" rel="noreferrer" >
|
||||
<h4>{item.listing.data.release.description}</h4>
|
||||
</a>
|
||||
<h5>{item.user.email}</h5>
|
||||
<h5 class='alt-text'>{item.total} - {item.condition}</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);
|
||||
|
||||
function sync() {
|
||||
ws.sendAcpListingsSync();
|
||||
}
|
||||
|
||||
return (
|
||||
<div class='acp'>
|
||||
<button onClick={sync}> sync listings </button>
|
||||
<button onClick={sync}> generate offers </button>
|
||||
<hr />
|
||||
<div class='bundles'>
|
||||
{offers.map(SellerGroup)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -1,56 +0,0 @@
|
||||
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> (this can take a few minutes time!) </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;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
import { useSelector } from 'react-redux'
|
||||
import { route } from 'preact-router';
|
||||
|
||||
import { State } from './../store'
|
||||
import * as toast from './../toast';
|
||||
|
||||
import Want from './../components/want';
|
||||
|
||||
const List = () => {
|
||||
const wants = useSelector((state: State) => state.wants);
|
||||
|
||||
if (!wants) {
|
||||
return <div> loading... </div>;
|
||||
}
|
||||
|
||||
if (!wants.length) {
|
||||
toast.info('List Empty', 'You have no items in your list, add some with search.');
|
||||
route('/search');
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="list">
|
||||
{wants.map(Want)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
||||
@@ -1,118 +0,0 @@
|
||||
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';
|
||||
|
||||
const Login = ({ mode }) => {
|
||||
const dispatch = useDispatch()
|
||||
const user = useSelector((state: State) => state.user);
|
||||
const ws = useSelector((state: State) => state.ws);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function gotoRegister() {
|
||||
return route('/register');
|
||||
}
|
||||
|
||||
async function postLogin(email, password) {
|
||||
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(email, password, confirm) {
|
||||
if (password !== confirm) return setError('passwords do not match');
|
||||
|
||||
const [error, user] = await http.postRegister(email, password);
|
||||
if (error) return setError(error);
|
||||
route('/search');
|
||||
dispatch(userState.actions.set(user));
|
||||
// reconnect ws to get logged in methods
|
||||
ws.connect();
|
||||
}
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
console.log(e.target.email, e.target.password, e.target.confirm);
|
||||
if (mode === 'login') return postLogin(e.target.email.value, e.target.password.value);
|
||||
if (mode === 'register') return postRegister(e.target.email.value, e.target.password.value, e.target.confirm.value);
|
||||
}
|
||||
|
||||
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="" onSubmit={handleSubmit}>
|
||||
<div class="item">
|
||||
<label for="email">email</label>
|
||||
<input type="email" name="email" id="email" ref={emailRef} required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="password">password</label>
|
||||
<input type="password" name="password" id="password" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="password">confirm password</label>
|
||||
<input type="password" name="confirm" id="confirm" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<input type="submit" value="register" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="login">
|
||||
{errorEl}
|
||||
<form action="" onSubmit={handleSubmit}>
|
||||
<div class="item">
|
||||
<label for="email">email</label>
|
||||
<input type="email" name="email" id="email" ref={emailRef} />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="password">password</label>
|
||||
<input type="password" name="password" id="password" />
|
||||
</div>
|
||||
<div class="item">
|
||||
<input type="submit" value="login" />
|
||||
</div>
|
||||
</form>
|
||||
<button onClick={gotoRegister}> create account </button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function LoginPage({ mode }) {
|
||||
return (
|
||||
<div class="landing">
|
||||
<Login mode={mode} />
|
||||
<Swirl />
|
||||
<div class='words'>
|
||||
<p>record importing service</p>
|
||||
<p>we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl</p>
|
||||
<p>then we get together and play them</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
// import { route } from 'preact-router';
|
||||
|
||||
import OfferGroup from "./offer.group.tsx";
|
||||
|
||||
const GroupNav = (want) => {
|
||||
if (!want._count.offers) return null
|
||||
const href = `#${want.release}`;
|
||||
|
||||
return (
|
||||
<div class=''>
|
||||
<a href={href}>{want.data.artists_sort} - {want.data.title}</a>
|
||||
<span>{want._count.offers}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function Main({ wants, offers }) {
|
||||
if (offers && offers.length === 0) {
|
||||
// Redirect to search if no offers
|
||||
if (typeof window !== 'undefined') {
|
||||
// route('/search');
|
||||
}
|
||||
}
|
||||
|
||||
if (!offers || !wants) {
|
||||
return (
|
||||
<div>
|
||||
<p>No wants or offers found.</p>
|
||||
<a href="/search" class="button">Search Records</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(offers)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<nav>
|
||||
<div class='header'>
|
||||
<span>Record</span>
|
||||
<span>Offers</span>
|
||||
</div>
|
||||
{wants.map(GroupNav)}
|
||||
</nav>
|
||||
<div class={'offers'}>
|
||||
{offers.map(OfferGroup)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Main;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { User } from 'prismaClient'
|
||||
|
||||
function showAdmin(user: User) {
|
||||
return user && user.email === 'ntr@mnml.gg';
|
||||
}
|
||||
|
||||
const Nav = ({ user }) => {
|
||||
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="/api/auth/logout" >Logout</a>
|
||||
</nav>
|
||||
)
|
||||
|
||||
return (
|
||||
<header>
|
||||
{user ? nav() : anonNav()}
|
||||
{showAdmin(user) ? adminNav() : <div />}
|
||||
{user ? userNav() : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Nav;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import { ExternalLink, Check } from 'lucide-preact';
|
||||
import _ from 'lodash';
|
||||
|
||||
function OfferLine(offer) {
|
||||
// Server-side rendering version doesn't support interactive status changes
|
||||
const accepted = offer.status === 'Accepted';
|
||||
const classes = `${accepted ? 'accepted' : ''}`;
|
||||
const btnText = accepted
|
||||
? offer.status
|
||||
: 'Accept';
|
||||
|
||||
return (
|
||||
<div class="line">
|
||||
<div class='number'>${offer.total}</div>
|
||||
<div class='number'>{offer.condition}</div>
|
||||
<div class='number'>{offer.status}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OfferPriceBtn(offer) {
|
||||
const accepted = offer.status === 'Accepted';
|
||||
const confirmed = offer.status === 'Confirmed';
|
||||
|
||||
const classes = `${accepted ? 'accepted' : ''} ${confirmed ? 'confirmed' : ''}`;
|
||||
const btnText = accepted
|
||||
? offer.status
|
||||
: 'Accept';
|
||||
|
||||
const title = accepted
|
||||
? 'Click to cancel'
|
||||
: 'Click to accept this offer';
|
||||
|
||||
return (
|
||||
<button disabled={true} title={title} class={classes}>${offer.total} {offer.condition}</button>
|
||||
)
|
||||
}
|
||||
|
||||
function OfferConditionSlider([condition, offers], i, groupId, rangeMin, rangeMax) {
|
||||
const step = 'any'
|
||||
const id = `${groupId}-${i}`
|
||||
const markersId = `${id}-markers`
|
||||
const steps = offers.map(o => o.total)
|
||||
const initialValue = _.last(offers.filter(o => o.status === 'Accepted'))?.total || 0
|
||||
const acceptedPriceLabel = initialValue ? `$${initialValue}` : '-'
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<label for={id}>{condition}</label><br />
|
||||
<label for={id}>{acceptedPriceLabel}</label><br />
|
||||
<input
|
||||
type="range"
|
||||
id={id}
|
||||
name={id}
|
||||
list={markersId}
|
||||
step={step}
|
||||
min={rangeMin}
|
||||
max={rangeMax}
|
||||
value={initialValue}
|
||||
disabled={true} />
|
||||
|
||||
<datalist id={markersId}>
|
||||
{offers.map(o => <option value={o.total}></option>)}
|
||||
</datalist>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function OfferGroup(group, i) {
|
||||
const format = group.details.formats[0].text ? `${group.details.formats[0].text} - ` : '';
|
||||
const descriptions = group.details.formats.map(f => f.descriptions.join(' ')).join(' ')
|
||||
const detailText = `${format} ${descriptions}`;
|
||||
|
||||
const conditions = Object.entries(_.groupBy(group.offers, 'condition'))
|
||||
const rangeMin = 0
|
||||
const rangeMax = _.max(group.offers.map(o => Number(o.total)))
|
||||
|
||||
const SHOW_BUTTONS = false
|
||||
const buttons = SHOW_BUTTONS ? (
|
||||
<div class="lines">
|
||||
{group.offers.map(o => OfferPriceBtn(o))}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
|
||||
return (
|
||||
<div class="offer" key={i} id={group.details.id}>
|
||||
<section>
|
||||
<h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3>
|
||||
<div class='alt-text details'>
|
||||
{group.details.year} - {detailText}
|
||||
</div>
|
||||
<div class='alt-text details'>Status: {group.status}</div>
|
||||
</section>
|
||||
<img src={group.details.thumb} />
|
||||
<div>
|
||||
{conditions.map((c, i) => OfferConditionSlider(c, i, group.details.id, rangeMin, rangeMax))}
|
||||
</div>
|
||||
{ buttons }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
import { ExternalLink, Check } from 'react-feather';
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { State } from './../store';
|
||||
import { info, error as toastError } from './../toast';
|
||||
|
||||
import http from '../http';
|
||||
|
||||
function Result(r, wants, ws) {
|
||||
async function onClick() {
|
||||
ws.sendWantAdd(r.id);
|
||||
}
|
||||
|
||||
// releaseIds are bigints on server
|
||||
const wanted = wants.some(w => BigInt(w.release) === BigInt(r.id));
|
||||
|
||||
const classes = `${wanted ? 'added' : ''}`;
|
||||
const btnText = wanted
|
||||
? <Check />
|
||||
: '+';
|
||||
|
||||
return (
|
||||
<article key={r.id}>
|
||||
<button onClick={onClick} disabled={wanted} class={classes} >{btnText}</button>
|
||||
<div>
|
||||
<h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank" rel="noreferrer"><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 [username, setUsername] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
const wants = useSelector((state: State) => state.wants);
|
||||
const ws = useSelector((state: State) => state.ws);
|
||||
|
||||
async function fetchResults() {
|
||||
const [error, results] = await http.postWantSearch(query);
|
||||
|
||||
if (error) {
|
||||
setResults([]);
|
||||
toastError('Error', error);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
setResults([]);
|
||||
return info('No Results', 'No results returned from search');
|
||||
}
|
||||
|
||||
setResults(results);
|
||||
}
|
||||
|
||||
async function submitWantlist() {
|
||||
const [error, wants] = await http.postDiscogs(username);
|
||||
|
||||
if (error) {
|
||||
setResults([]);
|
||||
toastError('Error', error);
|
||||
}
|
||||
|
||||
if (wants.length === 0) {
|
||||
setResults([]);
|
||||
return info('Success', 'No new additions');
|
||||
}
|
||||
|
||||
info('Success', `Added ${wants.length} items to your list.`);
|
||||
}
|
||||
|
||||
const searchRef = useRef<HTMLInputElement>();
|
||||
const resultsRef = useRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current.focus()
|
||||
}, [])
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
fetchResults();
|
||||
}
|
||||
|
||||
function handleDiscogsSubmit(e) {
|
||||
e.preventDefault();
|
||||
submitWantlist();
|
||||
}
|
||||
|
||||
const topEl = results.length
|
||||
? <button onClick={() => resultsRef.current.scrollTo(0, 0)}> top </button>
|
||||
: 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' ref={resultsRef}>
|
||||
{results.map(r => Result(r, wants, ws))}
|
||||
{topEl}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
@@ -1,87 +0,0 @@
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Barlow';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_A8s52Hs.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Barlow';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_Ass52Hs.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Barlow';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_DMs5.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;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09DdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09ndVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09HdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ097dVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09LdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09PdVXQQ.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
src: url(https://fonts.gstatic.com/s/notosansmono/v21/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;
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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: 'Barlow', 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-red: #ae4442;
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
button, [type=submit] {
|
||||
padding: var(--spacing-half);
|
||||
background: var(--background-1);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--secondary-font-color);
|
||||
font-family: var(--secondary-font-family);
|
||||
text-transform: uppercase;
|
||||
|
||||
&:hover {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.alt-text, .alt {
|
||||
font-family: var(--secondary-font-family);
|
||||
// font-size: var(--small-font-size);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@import 'fonts.less';
|
||||
@import 'styles.less';
|
||||
@@ -1,422 +0,0 @@
|
||||
body {
|
||||
padding-top: calc(var(--spacing)/2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mcrates {
|
||||
padding: 0 var(--spacing-double) var(--spacing) var(--spacing-double);
|
||||
display: grid;
|
||||
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main main"
|
||||
"footer footer";
|
||||
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
}
|
||||
|
||||
.landing {
|
||||
display: grid;
|
||||
|
||||
overflow: scroll;
|
||||
|
||||
grid-template-columns: 1fr 2fr;
|
||||
grid-gap: calc(var(--spacing-double) * 2);
|
||||
grid-template-areas:
|
||||
"login swirl"
|
||||
"login words";
|
||||
|
||||
.login {
|
||||
grid-area: login;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.swirl {
|
||||
grid-area: swirl;
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.words {
|
||||
grid-area: words;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
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;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.words {
|
||||
grid-area: words;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.swirl {
|
||||
grid-area: swirl;
|
||||
height: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
article {
|
||||
background-color: var(--background-1);
|
||||
|
||||
button {
|
||||
border-color: var(--background-2);
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
padding-left: var(--spacing);
|
||||
grid-gap: var(--spacing);
|
||||
|
||||
article {
|
||||
display: grid;
|
||||
padding: var(--spacing);
|
||||
grid-template-columns: 1fr 4fr min-content;
|
||||
}
|
||||
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
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);
|
||||
overflow: scroll;
|
||||
|
||||
article {
|
||||
display: grid;
|
||||
|
||||
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 {
|
||||
margin-bottom: var(--spacing);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
header {
|
||||
grid-area: header;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr min-content;
|
||||
|
||||
border-bottom: var(--base-border);
|
||||
padding-bottom: var(--spacing-half);
|
||||
margin-bottom: var(--spacing-half);
|
||||
|
||||
a {
|
||||
font-size: var(--small-font-size);
|
||||
margin-right: var(--spacing);
|
||||
}
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
grid-area: main;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"nav content";
|
||||
|
||||
grid-template-columns:
|
||||
1fr 1fr;
|
||||
|
||||
column-gap: var(--spacing);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
nav {
|
||||
// duck scrollbars
|
||||
padding-right: var(--spacing-half);
|
||||
|
||||
grid-area: nav;
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
1fr min-content min-content;
|
||||
|
||||
column-gap: var(--spacing-half);
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.offers {
|
||||
grid-area: content;
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.acp {
|
||||
overflow: scroll;
|
||||
|
||||
.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("/swirl.svg");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
|
||||
margin-bottom: var(--spacing);
|
||||
}
|
||||
|
||||
input[type='range']{ accent-color: var(--color-accepted); }
|
||||
|
||||
@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";
|
||||
}
|
||||
|
||||
#mcrates {
|
||||
padding: 0 var(--spacing) 0 var(--spacing);
|
||||
}
|
||||
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 3fr;
|
||||
grid-gap: var(--spacing-half);
|
||||
grid-template-areas:
|
||||
"nav"
|
||||
"content";
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:1200px) {
|
||||
img {
|
||||
width: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------
|
||||
// toasts
|
||||
|
||||
.iziToast {
|
||||
border-radius: 0;
|
||||
border-color: var(--base-border-color);
|
||||
|
||||
.iziToast-body {
|
||||
font-family: var(--base-font-family);
|
||||
font-size: var(--base-font-size);
|
||||
}
|
||||
|
||||
.iziToast-title {
|
||||
font-family: var(--secondary-font-family);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"settings": {
|
||||
"LSP": {
|
||||
"LSP-typescript": {
|
||||
"enabled": false,
|
||||
},
|
||||
"Deno": {
|
||||
"enabled": true,
|
||||
"settings": {
|
||||
"deno.config": "./deno.jsonc",
|
||||
"deno.unstable": true,
|
||||
"deno.suggest.imports.hosts": {
|
||||
"https://deno.land": true,
|
||||
"https://some-other-cdn.com": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"LSP-pyright":
|
||||
{
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
"folders":
|
||||
[
|
||||
{
|
||||
"path": ".",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"lock": false,
|
||||
|
||||
"unstable": ["kv"],
|
||||
|
||||
"tasks": {
|
||||
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
|
||||
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
|
||||
"manifest": "deno task cli manifest $(pwd)",
|
||||
"start": "deno run -A --unstable-kv --watch=routes/,.env dev.ts",
|
||||
"build": "deno run -A --unstable-kv dev.ts build",
|
||||
"preview": "deno run -A main.ts",
|
||||
"prisma-clean": "rm -rf prisma/generated",
|
||||
"prisma-generate": "deno task prisma-clean && deno run -A --env-file npm:prisma generate",
|
||||
"prisma:migrate": "deno run -A --env-file npm:prisma migrate dev",
|
||||
"update": "deno run -A -r https://fresh.deno.dev/update ."
|
||||
},
|
||||
|
||||
"imports": {
|
||||
"$std/": "https://deno.land/std@0.217.0/",
|
||||
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||
"preact": "https://esm.sh/preact@10.22.0",
|
||||
"preact/": "https://esm.sh/preact@10.22.0/",
|
||||
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
|
||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
||||
"@twind/core": "https://esm.sh/@twind/core@1.1.3",
|
||||
"@twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4/",
|
||||
"@twind/preset-autoprefix": "https://esm.sh/@twind/preset-autoprefix@1.0.7/",
|
||||
|
||||
"lodash": "https://deno.land/x/lodash@4.17.15-es/lodash.js",
|
||||
"less": "npm:less@4.2.0",
|
||||
"lucide-preact": "npm:lucide-preact@^0.475.0",
|
||||
"@prisma/client": "npm:@prisma/client@^6.6.0",
|
||||
"zodix": "npm:zodix@^0.4.4",
|
||||
|
||||
|
||||
"api/": "./api/",
|
||||
"api": "./api/",
|
||||
"prisma/": "./prisma/",
|
||||
"prisma": "./prisma/",
|
||||
"prismaClient": "./prisma/generated/index.ts",
|
||||
},
|
||||
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"indentWidth": 4,
|
||||
"semiColons": false,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve"
|
||||
},
|
||||
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": [
|
||||
"fresh",
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"**/_fresh/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
|
||||
"nodeModulesDir": "auto"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env -S deno run -A --watch=static/,routes/
|
||||
|
||||
import dev from "$fresh/dev.ts";
|
||||
import config from "./fresh.config.ts";
|
||||
|
||||
import "$std/dotenv/load.ts";
|
||||
|
||||
await dev(import.meta.url, "./main.ts", config);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "$fresh/server.ts";
|
||||
// import twind from "$fresh/plugins/twindv1.ts";
|
||||
// import twindConfig from "./twind.config.ts";
|
||||
import lessPlugin from "./less.plugin.ts";
|
||||
|
||||
const less = lessPlugin()
|
||||
await less.buildStart()
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
// twind(twindConfig),
|
||||
lessPlugin()
|
||||
],
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
// DO NOT EDIT. This file is generated by Fresh.
|
||||
// This file SHOULD be checked into source version control.
|
||||
// This file is automatically updated during development when running `dev.ts`.
|
||||
|
||||
import * as $_404 from './routes/_404.tsx'
|
||||
import * as $_500 from './routes/_500.tsx'
|
||||
import * as $_app from './routes/_app.tsx'
|
||||
import * as $_middleware from './routes/_middleware.ts'
|
||||
import * as $api_auth_login from './routes/api/auth/login.ts'
|
||||
import * as $api_auth_logout from './routes/api/auth/logout.ts'
|
||||
import * as $api_auth_register from './routes/api/auth/register.ts'
|
||||
import * as $api_auth_status from './routes/api/auth/status.ts'
|
||||
import * as $api_offers from './routes/api/offers.ts'
|
||||
import * as $api_wants from './routes/api/wants.ts'
|
||||
import * as $home from './routes/home.tsx'
|
||||
import * as $index from './routes/index.tsx'
|
||||
import * as $login from './routes/login.tsx'
|
||||
import * as $register from './routes/register.tsx'
|
||||
|
||||
import type { Manifest } from '$fresh/server.ts'
|
||||
|
||||
const manifest = {
|
||||
routes: {
|
||||
'./routes/_404.tsx': $_404,
|
||||
'./routes/_500.tsx': $_500,
|
||||
'./routes/_app.tsx': $_app,
|
||||
'./routes/_middleware.ts': $_middleware,
|
||||
'./routes/api/auth/login.ts': $api_auth_login,
|
||||
'./routes/api/auth/logout.ts': $api_auth_logout,
|
||||
'./routes/api/auth/register.ts': $api_auth_register,
|
||||
'./routes/api/auth/status.ts': $api_auth_status,
|
||||
'./routes/api/offers.ts': $api_offers,
|
||||
'./routes/api/wants.ts': $api_wants,
|
||||
'./routes/home.tsx': $home,
|
||||
'./routes/index.tsx': $index,
|
||||
'./routes/login.tsx': $login,
|
||||
'./routes/register.tsx': $register,
|
||||
},
|
||||
islands: {},
|
||||
baseUrl: import.meta.url,
|
||||
} satisfies Manifest
|
||||
|
||||
export default manifest
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Plugin } from "$fresh/server.ts";
|
||||
import { ensureDir } from "$std/fs/mod.ts";
|
||||
import { join } from "$std/path/mod.ts";
|
||||
import { dirname } from "$std/path/mod.ts";
|
||||
|
||||
// Import Less directly
|
||||
import less from "less";
|
||||
|
||||
interface LessPluginOptions {
|
||||
path: string;
|
||||
outDir: string;
|
||||
}
|
||||
|
||||
export default function lessPlugin(): Plugin {
|
||||
const path = "components/style"
|
||||
const outDir = "static/css"
|
||||
|
||||
// Compile the Less file using the Less package
|
||||
async function compileLess(inputPath: string, outputPath: string): Promise<void> {
|
||||
try {
|
||||
const lessContent = await Deno.readTextFile(inputPath);
|
||||
const lessOptions = {
|
||||
filename: inputPath, // This helps with resolving imports
|
||||
paths: [dirname(inputPath)],
|
||||
};
|
||||
|
||||
const output = await less.render(lessContent, lessOptions);
|
||||
await Deno.writeTextFile(outputPath, output.css);
|
||||
console.log(`Compiled ${inputPath} to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("Less compilation error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "lessPlugin",
|
||||
async buildStart() {
|
||||
// Ensure output directory exists
|
||||
const outputDir = join(Deno.cwd(), outDir);
|
||||
await ensureDir(outputDir);
|
||||
|
||||
// Only process index.less which imports the others
|
||||
const inputPath = join(Deno.cwd(), path, "index.less");
|
||||
const outputPath = join(outputDir, "style.css");
|
||||
|
||||
// Initial compilation
|
||||
await compileLess(inputPath, outputPath);
|
||||
console.log("Less styles compiled during build start");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/// <reference no-default-lib="true" />
|
||||
/// <reference lib="dom" />
|
||||
/// <reference lib="dom.iterable" />
|
||||
/// <reference lib="dom.asynciterable" />
|
||||
/// <reference lib="deno.ns" />
|
||||
|
||||
import "$std/dotenv/load.ts";
|
||||
|
||||
import { start } from "$fresh/server.ts";
|
||||
import manifest from "./fresh.gen.ts";
|
||||
import config from "./fresh.config.ts";
|
||||
|
||||
await start(manifest, config);
|
||||
@@ -1,608 +0,0 @@
|
||||
{
|
||||
"name": "crates",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz",
|
||||
"integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz",
|
||||
"integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz",
|
||||
"integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz",
|
||||
"integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz",
|
||||
"integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz",
|
||||
"integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz",
|
||||
"integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz",
|
||||
"integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz",
|
||||
"integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz",
|
||||
"integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz",
|
||||
"integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz",
|
||||
"integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.6.0.tgz",
|
||||
"integrity": "sha512-vfp73YT/BHsWWOAuthKQ/1lBgESSqYqAWZEYyTdGXyFAHpmewwWL2Iz6ErIzkj4aHbuc6/cGSsE6ZY+pBO04Cg==",
|
||||
"hasInstallScript": true,
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prisma": "*",
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"prisma": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.6.0.tgz",
|
||||
"integrity": "sha512-d8FlXRHsx72RbN8nA2QCRORNv5AcUnPXgtPvwhXmYkQSMF/j9cKaJg+9VcUzBRXGy9QBckNzEQDEJZdEOZ+ubA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"esbuild": ">=0.12 <1",
|
||||
"esbuild-register": "3.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.6.0.tgz",
|
||||
"integrity": "sha512-DL6n4IKlW5k2LEXzpN60SQ1kP/F6fqaCgU/McgaYsxSf43GZ8lwtmXLke9efS+L1uGmrhtBUP4npV/QKF8s2ZQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.6.0.tgz",
|
||||
"integrity": "sha512-nC0IV4NHh7500cozD1fBoTwTD1ydJERndreIjpZr/S3mno3P6tm8qnXmIND5SwUkibNeSJMpgl4gAnlqJ/gVlg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.6.0",
|
||||
"@prisma/engines-version": "6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a",
|
||||
"@prisma/fetch-engine": "6.6.0",
|
||||
"@prisma/get-platform": "6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a.tgz",
|
||||
"integrity": "sha512-JzRaQ5Em1fuEcbR3nUsMNYaIYrOT1iMheenjCvzZblJcjv/3JIuxXN7RCNT5i6lRkLodW5ojCGhR7n5yvnNKrw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.6.0.tgz",
|
||||
"integrity": "sha512-Ohfo8gKp05LFLZaBlPUApM0M7k43a0jmo86YY35u1/4t+vuQH9mRGU7jGwVzGFY3v+9edeb/cowb1oG4buM1yw==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.6.0",
|
||||
"@prisma/engines-version": "6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a",
|
||||
"@prisma/get-platform": "6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.6.0.tgz",
|
||||
"integrity": "sha512-3qCwmnT4Jh5WCGUrkWcc6VZaw0JY7eWN175/pcb5Z6FiLZZ3ygY93UX0WuV41bG51a6JN/oBH0uywJ90Y+V5eA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
|
||||
"integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.3",
|
||||
"@esbuild/android-arm": "0.25.3",
|
||||
"@esbuild/android-arm64": "0.25.3",
|
||||
"@esbuild/android-x64": "0.25.3",
|
||||
"@esbuild/darwin-arm64": "0.25.3",
|
||||
"@esbuild/darwin-x64": "0.25.3",
|
||||
"@esbuild/freebsd-arm64": "0.25.3",
|
||||
"@esbuild/freebsd-x64": "0.25.3",
|
||||
"@esbuild/linux-arm": "0.25.3",
|
||||
"@esbuild/linux-arm64": "0.25.3",
|
||||
"@esbuild/linux-ia32": "0.25.3",
|
||||
"@esbuild/linux-loong64": "0.25.3",
|
||||
"@esbuild/linux-mips64el": "0.25.3",
|
||||
"@esbuild/linux-ppc64": "0.25.3",
|
||||
"@esbuild/linux-riscv64": "0.25.3",
|
||||
"@esbuild/linux-s390x": "0.25.3",
|
||||
"@esbuild/linux-x64": "0.25.3",
|
||||
"@esbuild/netbsd-arm64": "0.25.3",
|
||||
"@esbuild/netbsd-x64": "0.25.3",
|
||||
"@esbuild/openbsd-arm64": "0.25.3",
|
||||
"@esbuild/openbsd-x64": "0.25.3",
|
||||
"@esbuild/sunos-x64": "0.25.3",
|
||||
"@esbuild/win32-arm64": "0.25.3",
|
||||
"@esbuild/win32-ia32": "0.25.3",
|
||||
"@esbuild/win32-x64": "0.25.3"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-register": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
|
||||
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": ">=0.12 <1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.6.0.tgz",
|
||||
"integrity": "sha512-SYCUykz+1cnl6Ugd8VUvtTQq5+j1Q7C0CtzKPjQ8JyA2ALh0EEJkMCS+KgdnvKW1lrxjtjCyJSHOOT236mENYg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.6.0",
|
||||
"@prisma/engines": "6.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prisma": "^6.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"zod": "^3.24.3",
|
||||
"zodix": "^0.4.4"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "listings" (
|
||||
"id" BIGINT NOT NULL,
|
||||
"seller" BIGINT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"wantId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "listings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wants" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"release" BIGINT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "wants_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "offers" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"status" TEXT NOT NULL,
|
||||
"condition" TEXT NOT NULL DEFAULT '',
|
||||
"item" MONEY NOT NULL,
|
||||
"shipping" MONEY NOT NULL,
|
||||
"total" MONEY NOT NULL,
|
||||
"listingId" BIGINT NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"wantId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "offers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "listings_seller_idx" ON "listings"("seller");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wants_release_idx" ON "wants"("release");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wants_userId_release_key" ON "wants"("userId", "release");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "offers_listingId_key" ON "offers"("listingId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "listings" ADD CONSTRAINT "listings_wantId_fkey" FOREIGN KEY ("wantId") REFERENCES "wants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wants" ADD CONSTRAINT "wants_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "offers" ADD CONSTRAINT "offers_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "listings"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "offers" ADD CONSTRAINT "offers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "offers" ADD CONSTRAINT "offers_wantId_fkey" FOREIGN KEY ("wantId") REFERENCES "wants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,111 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION notify_users() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
record RECORD;
|
||||
id UUID;
|
||||
payload JSON;
|
||||
BEGIN
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
id = OLD.id;
|
||||
ELSE
|
||||
id = NEW.id;
|
||||
END IF;
|
||||
|
||||
payload = json_build_object(
|
||||
'table', TG_TABLE_NAME,
|
||||
'action', TG_OP,
|
||||
'id', id
|
||||
);
|
||||
|
||||
PERFORM pg_notify('users.events', payload::text);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_listings() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
record RECORD;
|
||||
id int8;
|
||||
payload JSON;
|
||||
BEGIN
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
id = OLD.id;
|
||||
ELSE
|
||||
id = NEW.id;
|
||||
END IF;
|
||||
|
||||
payload = json_build_object(
|
||||
'table', TG_TABLE_NAME,
|
||||
'action', TG_OP,
|
||||
'id', id
|
||||
);
|
||||
|
||||
PERFORM pg_notify('listings.events', payload::text);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_offers() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
record RECORD;
|
||||
id UUID;
|
||||
payload JSON;
|
||||
BEGIN
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
id = OLD.id;
|
||||
ELSE
|
||||
id = NEW.id;
|
||||
END IF;
|
||||
|
||||
payload = json_build_object(
|
||||
'table', TG_TABLE_NAME,
|
||||
'action', TG_OP,
|
||||
'id', id
|
||||
);
|
||||
|
||||
PERFORM pg_notify('offers.events', payload::text);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_wants() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
record RECORD;
|
||||
id UUID;
|
||||
payload JSON;
|
||||
BEGIN
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
id = OLD.id;
|
||||
ELSE
|
||||
id = NEW.id;
|
||||
END IF;
|
||||
|
||||
payload = json_build_object(
|
||||
'table', TG_TABLE_NAME,
|
||||
'action', TG_OP,
|
||||
'id', id
|
||||
);
|
||||
|
||||
PERFORM pg_notify('wants.events', payload::text);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER notify_wants_event
|
||||
AFTER INSERT OR UPDATE OR DELETE ON wants
|
||||
FOR EACH ROW EXECUTE PROCEDURE notify_wants();
|
||||
|
||||
CREATE OR REPLACE TRIGGER notify_listings_event
|
||||
AFTER INSERT OR UPDATE OR DELETE ON listings
|
||||
FOR EACH ROW EXECUTE PROCEDURE notify_listings();
|
||||
|
||||
CREATE OR REPLACE TRIGGER notify_offers_event
|
||||
AFTER INSERT OR UPDATE OR DELETE ON offers
|
||||
FOR EACH ROW EXECUTE PROCEDURE notify_offers();
|
||||
|
||||
CREATE OR REPLACE TRIGGER notify_users_event
|
||||
AFTER INSERT OR UPDATE OR DELETE ON users
|
||||
FOR EACH ROW EXECUTE PROCEDURE notify_users();
|
||||
@@ -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"
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Head } from "$fresh/runtime.ts";
|
||||
|
||||
export default function Error404() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>404 - Page not found</title>
|
||||
</Head>
|
||||
<div class="px-4 py-8 mx-auto bg-[#86efac]">
|
||||
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
|
||||
<img
|
||||
class="my-6"
|
||||
src="/swirl.svg"
|
||||
width="128"
|
||||
height="128"
|
||||
alt="the Fresh logo: a sliced lemon dripping with juice"
|
||||
/>
|
||||
<h1 class="text-4xl font-bold">404 - Page not found</h1>
|
||||
<p class="my-4">
|
||||
The page you were looking for doesn't exist.
|
||||
</p>
|
||||
<a href="/" class="underline">Go back home</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { PageProps } from "$fresh/server.ts";
|
||||
import { Head } from "$fresh/runtime.ts";
|
||||
|
||||
export default function Error500Page({ error }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>500 - Server Error</title>
|
||||
</Head>
|
||||
<div class="px-4 py-8 mx-auto bg-[#86efac]">
|
||||
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
|
||||
<img
|
||||
class="my-6"
|
||||
src="/swirl.svg"
|
||||
width="128"
|
||||
height="128"
|
||||
alt="the Fresh logo: a sliced lemon dripping with juice"
|
||||
/>
|
||||
<h1 class="text-4xl font-bold">500 - Server Error</h1>
|
||||
<p class="my-4">
|
||||
{(error as Error).message}
|
||||
</p>
|
||||
<a href="/" class="underline">Go back home</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type PageProps } from "$fresh/server.ts";
|
||||
|
||||
export default function App({ Component }: PageProps) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/css/base.css" />
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<title>crates</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mcrates">
|
||||
<Component />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { errorMiddleware } from 'api/mw.error.ts'
|
||||
import { serviceMiddleware } from 'api/mw.service.ts'
|
||||
import { authMiddleware } from 'api/mw.auth.ts'
|
||||
|
||||
export const handler = [errorMiddleware, serviceMiddleware, authMiddleware]
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Handlers } from "$fresh/server.ts"
|
||||
import { setCookie } from "$std/http/cookie.ts"
|
||||
import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"
|
||||
|
||||
import { z } from "npm:zod"
|
||||
import { zx } from "npm:zodix"
|
||||
|
||||
import { createSession } from "api/mw.auth.ts"
|
||||
import { InvalidCredentialsError } from "api/errors.ts"
|
||||
import { CratesState } from 'api/mw.service.ts'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
export const handler: Handlers<{email: string}, CratesState> = {
|
||||
async POST(req, ctx) {
|
||||
const { email, password } = await zx.parseForm(req, LoginSchema)
|
||||
|
||||
// Create pg instance for database operations
|
||||
const { pg } = ctx.state
|
||||
|
||||
// Find user by email
|
||||
const user = await pg.userFindEmail(email)
|
||||
if (!user) throw new InvalidCredentialsError()
|
||||
|
||||
// Verify password
|
||||
const match = await bcrypt.compare(password, user.password)
|
||||
if (!match) throw new InvalidCredentialsError()
|
||||
|
||||
// Create session using Deno KV
|
||||
const { sessionId, expiresAt } = await createSession(user.id, 24 * 7) // 7 days in hours
|
||||
|
||||
// Set cookie
|
||||
const headers = new Headers({ "Content-Type": "application/json" })
|
||||
setCookie(headers, {
|
||||
name: "session",
|
||||
value: sessionId,
|
||||
expires: expiresAt,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax"
|
||||
})
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: "/home" }
|
||||
});
|
||||
},
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Handlers } from "$fresh/server.ts"
|
||||
import { getCookies, setCookie } from "$std/http/cookie.ts"
|
||||
import { deleteSession } from "api/mw.auth.ts"
|
||||
|
||||
export const handler: Handlers = {
|
||||
async POST(req, _ctx) {
|
||||
const cookies = getCookies(req.headers)
|
||||
const sessionId = cookies.session
|
||||
|
||||
if (sessionId) {
|
||||
// Remove session from Deno KV
|
||||
await deleteSession(sessionId)
|
||||
}
|
||||
|
||||
// Clear the cookie
|
||||
const headers = new Headers({ "Content-Type": "application/json" })
|
||||
setCookie(headers, {
|
||||
name: "session",
|
||||
value: "",
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax"
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers
|
||||
})
|
||||
}
|
||||
}
|
||||