38 Commits
Author SHA1 Message Date
Nathan Rashleigh b6c6e54ef8 home index 2025-05-02 01:32:28 +10:00
Nathan Rashleigh a4d8608a3e mw stuff 2025-05-02 01:07:56 +10:00
Nathan Rashleigh bdc22c5c42 home back 2025-05-01 23:32:49 +10:00
Nathan Rashleigh a79c11496b auth mw stuff 2025-05-01 21:25:06 +10:00
Nathan Rashleigh d868baade5 prisma types 2025-04-29 20:34:01 +10:00
Nathan Rashleigh ff371798a9 back 2025-04-29 18:52:36 +10:00
Nathan Rashleigh 2eb10eb4d6 less and logins 2025-04-29 18:02:15 +10:00
Nathan Rashleigh 4e211ecedf components 2025-04-25 19:01:47 +10:00
Nathan Rashleigh d6f1b4d879 swirl 2025-04-25 16:16:49 +10:00
Nathan Rashleigh d747244a4d fix bcrypt 2025-04-25 16:15:12 +10:00
Nathan Rashleigh 534b90e325 deno 2025-04-25 16:09:59 +10:00
Nathan Rashleigh 1f799ce538 justfile 2025-04-25 12:34:08 +10:00
Nathan Rashleigh f2d805b7e5 update for ntwl.xyz 2025-04-25 12:14:13 +10:00
Nathan Rashleigh 54b1900c88 offers sliders 2024-10-07 00:12:58 +11:00
Nathan Rashleigh 5e86a52d43 Sliders for accepting 2024-10-06 00:25:53 +10:00
Nathan Rashleigh 445821aa10 we're back on windows bigtime 2024-10-05 21:55:09 +10:00
Nathan Rashleigh 29610b49b2 fixes for cloudflare 2024-09-11 00:05:04 +10:00
Nathan Rashleigh ae83ad54c4 catch status properly 2024-09-10 22:24:59 +10:00
Nathan Rashleigh b340fc7b61 we're so back 2024-09-08 23:16:47 +10:00
ntr 986b61962a Merge branch 'master' of ssh://mnml-studios:/home/ntr/mcrates 2024-01-05 21:33:10 +11:00
ntr 3a0525fffe k8s and dockerfiles 2023-12-18 22:44:12 +11:00
ntr 278e1c4a95 typo 2023-12-06 11:50:45 +11:00
ntr c379ef925d Merge branch 'master' of ssh://mnml-studios:/home/ntr/mcrates 2023-02-14 18:19:59 +11:00
ntr e4b7356b6e todo 2023-02-14 18:19:34 +11:00
ntr 19e0376edf handle weird rejected private listings 2023-02-10 17:32:50 +11:00
ntr 2dbbf75369 fix prd env 2023-02-09 00:21:00 +11:00
ntr fa703b678f discord bot 2023-02-09 00:16:47 +11:00
ntr ac0a31511d fix errors for discogs and use env settings in mcrates compose 2023-02-08 23:05:23 +11:00
ntr 2ffb5f9bc3 use [err, res] pattern for server axios 2023-02-08 22:51:09 +11:00
ntr 306dc30499 todo updates' 2023-02-07 23:50:39 +11:00
ntr 36fc3665dd sync constantly 2023-02-07 23:04:06 +11:00
ntr c6bf50f963 nicer toasts and fix some discogs sync problems 2023-02-07 22:11:38 +11:00
ntr 1ca9a7225c env files, fixes for discogs sync 2023-02-07 18:23:54 +11:00
ntr 1ee8dbc749 sort by title and scroll landing 2023-02-06 00:02:46 +11:00
ntr c4cc12850d fix acp scrolling 2023-02-02 21:29:40 +11:00
ntr f3813c0f61 Merge branch 'master' of ssh://git.mnml.gg:40022/home/ntr/mcrates 2023-01-26 15:24:15 +11:00
ntr f76ceb011e lint 2023-01-26 15:24:05 +11:00
ntr 84a19fbde3 simplify events 2023-01-23 18:42:41 +11:00
115 changed files with 11227 additions and 684 deletions

No files matched your search

+9 -1
View File
@@ -1,3 +1,11 @@
node_modules/ node_modules/
data/ data/
db/ db/
.env
*sql.gz
crates/prisma/generated/client
crates.sublime-workspace
crates/static/css
crates/prisma/generated
+5
View File
@@ -0,0 +1,5 @@
- 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
+20 -5
View File
@@ -1,15 +1,30 @@
# TODO # TODO
push offers
add last synced time to want - 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 go over and reorganise event logic
should be connection.service ?
contact options on landing page contact options on landing page
skip unofficial release skip unofficial release
status stepper status stepper
queue up wantlist adds to gracefully handle rate limiting
+1
View File
@@ -0,0 +1 @@
node_modules/
-7
View File
@@ -1,7 +0,0 @@
# 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"
+14
View File
@@ -0,0 +1,14 @@
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"]
Executable
+1
View File
@@ -0,0 +1 @@
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
+5 -1
View File
@@ -24,12 +24,16 @@ async function start() {
prisma, prisma,
pg, pg,
discogs, discogs,
discord,
api, api,
events, events,
notify, notify,
} = createServices(log); } = await createServices(log);
await notify.listen(); await notify.listen();
await discord.connect();
// events.emit(Events.Sync);
api.listen(); api.listen();
} }
Generated Regular → Executable
+3695 -186
View File
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "mates-crates", "name": "crates",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "mocha", "test": "mocha",
"test:watch": "mocha --watch", "test:watch": "mocha --watch",
"migrate": "knex migrate:latest", "migrate": "prisma migrate status",
"migrate:make": "prisma migrate dev --name", "migrate:make": "prisma migrate dev --name",
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short" "start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
}, },
@@ -21,7 +21,6 @@
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"axios": "^0.27.2", "axios": "^0.27.2",
"mocha": "^10.0.0", "mocha": "^10.0.0",
"node-dev": "^7.4.3",
"prisma": "^4.5.0", "prisma": "^4.5.0",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^4.7.4" "typescript": "^4.7.4"
@@ -34,6 +33,8 @@
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"bunyan": "^1.8.15", "bunyan": "^1.8.15",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"discord.js": "^14.7.1",
"esrun": "^3.2.26",
"koa": "^2.13.4", "koa": "^2.13.4",
"koa-body": "^5.0.0", "koa-body": "^5.0.0",
"koa-passport": "^5.0.0", "koa-passport": "^5.0.0",
@@ -42,7 +43,10 @@
"koa-ws": "^1.0.1", "koa-ws": "^1.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg-listen": "^1.7.0" "pg-listen": "^1.7.0",
"puppeteer": "^23.3.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2"
}, },
"overrides": { "overrides": {
"koa-passport": { "koa-passport": {
-8
View File
@@ -48,12 +48,4 @@ describe('api', function apiTests() {
assert(res.headers['set-cookie']); 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 });
});
});
}); });
+9 -2
View File
@@ -29,6 +29,11 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
const app = koaWebsocket(new koa()); 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) => { app.use(async (ctx, next) => {
try { try {
await next(); await next();
@@ -46,6 +51,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
function listen() { function listen() {
const server = app.listen(PORT); const server = app.listen(PORT);
log.info({ PORT }, 'listening'); log.info({ PORT }, 'listening');
return server;
} }
app.use(cors({ credentials: true })); app.use(cors({ credentials: true }));
@@ -54,7 +60,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
app.keys = [SECRET]; app.keys = [SECRET];
app.use(session(app)); app.use(session(app));
const { authRouter, passport } = routerAuth(log, pg); const { authRouter, passport } = routerAuth(log, pg, events);
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()) app.use(passport.session())
app.use(authRouter.routes()); app.use(authRouter.routes());
@@ -66,9 +72,10 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
router.post('/want/discogs', async function wantListPost(ctx, next) { router.post('/want/discogs', async function wantListPost(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required'); ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { username } = ctx.request.body; const { username } = ctx.request.body;
ctx.assert(username, 'no username supplied');
const user = await pg.userFindId(ctx.state.user.id); const user = await pg.userFindId(ctx.state.user.id);
const wants = await discogs.wantListSync(username, user); const wants = await discogs.wantListSync(username, user);
events.emit(Events.WantsAdded, [wants]); events.emit(Events.WantsAdded, wants);
ctx.response.body = wants; ctx.response.body = wants;
}); });
+58 -42
View File
@@ -1,23 +1,21 @@
import fs from 'fs/promises';
import axios from 'axios'; import axios from 'axios';
import cheerio from 'cheerio'; import pptr from 'puppeteer-extra'
import Bluebird from 'bluebird'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing } from '@prisma/client'; import type { Want, User } from '@prisma/client';
import { logAxiosError, wantDetails } from './utils'; import { wantDetails } from './utils';
import { Pg } from './pg'; import { Pg } from './pg';
// margh export default async function createDiscogs(log: Log, pg: Pg) {
// vanderbabe const TOKEN = process.env.DISCOGS_TOKEN;
// jmcdermid1
export default function createDiscogs(log: Log, pg: Pg) {
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
const apiBase = 'https://api.discogs.com' const apiBase = 'https://api.discogs.com'
const discogsBase = 'https://www.discogs.com'
pptr.use(StealthPlugin())
const browser = await pptr.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] })
log.info('browser created')
const api = axios.create({ const api = axios.create({
headers: { headers: {
@@ -27,24 +25,27 @@ export default function createDiscogs(log: Log, pg: Pg) {
}, },
}); });
async function handleApiError(e) { function onSuccess(response) {
logAxiosError(e); return response.data;
if (e.response.status === 429) {
throw new Error('rate limited');
}
return null;
} }
async function wantListSync(username: string, user: User): Promise<Want[]> { function onError(error) {
const { data: { wants, pagination }} = await api.get(`${apiBase}/users/${username}/wants`); throw error;
const releases = await Promise.all(wants.map(want => release(want.id))); }
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 releases = await Promise.all(wants.map(want => release(want.id)));
log.info({ wants: wants.length, user: username, userId: user.id }, 'fetched discogs wantlist'); log.info({ wants: wants.length, user: username, userId: user.id }, 'fetched discogs wantlist');
// TODO filter releases to be vinyl format only // TODO filter releases to be vinyl format only
// convert want object to actual release and ignore duplicate errors // convert want object to actual release and ignore duplicate errors
const results = await Promise.allSettled(releases.map(release => pg.wantCreate(release, user))); const results = await Promise.allSettled(releases.map(release => pg.wantCreate(release, user)));
log.info(results);
// @ts-ignore // @ts-ignore
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value); const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
@@ -54,7 +55,7 @@ export default function createDiscogs(log: Log, pg: Pg) {
} }
async function release(id: DiscogsRelease): Promise<any> { 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; return data;
} }
@@ -63,44 +64,59 @@ export default function createDiscogs(log: Log, pg: Pg) {
url.searchParams.append('query', query); url.searchParams.append('query', query);
url.searchParams.append('type', 'release'); url.searchParams.append('type', 'release');
url.searchParams.append('format', 'vinyl'); url.searchParams.append('format', 'vinyl');
const { data: { results } } = await api.get(url.href); const res = await api.get(url.href);
const { results } = res;
return results; return results;
} }
async function inventory(user: string) { async function inventory(user: string) {
const res = await api.get(`${apiBase}/users/${user}/inventory`); const res = await api.get(`${apiBase}/users/${user}/inventory`);
return res.data.listings; return res.listings;
} }
async function listings(want: Want) { async function listings(want: Want) {
const res = await api.get(`https://www.discogs.com/sell/release/${want.release}?limit=250`); const page = await browser.newPage()
const $ = cheerio.load(res.data); const url = `https://www.discogs.com/sell/release/${want.release}?limit=250`
const ids = $('.item_description_title[href]') const res = await page.goto(url)
.map(function getListingId() { log.debug({ status: res.status() }, 'status')
const id = Number.parseInt(this.attribs.href.replace('/sell/item/', ''), 10); if (!res.ok()) throw new Error(res.statusText())
return id; const els = await page.$$('.item_description_title[href]')
}) const hrefs = await Promise.all(els.map(e => e.evaluate(e => e.href)))
.get(); 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 unlisted = await pg.listingRmUnlisted(want, ids); const unlisted = await pg.listingRmUnlisted(want, ids);
if (unlisted) log.info({ unlisted: unlisted, ...wantDetails(want) }, 'missing listings removed'); if (unlisted) log.info({ unlisted: unlisted, ...wantDetails(want) }, 'missing listings removed');
// preferentially take listings from sellers if we hit req limits // preferentially take listings from sellers if we hit req limits
return Bluebird.mapSeries(ids, async id => listing(id, want));
// 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;
} }
async function listing(id, want) { async function listing(id, want) {
const listing = await pg.listingFind(id); const listing = await pg.listingFind(id);
if (listing) return listing; if (listing) return listing;
try { log.debug('fetching listing', id);
log.debug('fetching listing', id); const discogsListing = await api.get(`${apiBase}/marketplace/listings/${id}`);
const res = await api.get(`${apiBase}/marketplace/listings/${id}`); log.debug(discogsListing);
log.debug(res.data); return pg.listingUpsert(discogsListing, want);
return pg.listingUpsert(res.data, want);
} catch (e) {
return handleApiError(e);
}
} }
return { return {
+36
View File
@@ -0,0 +1,36 @@
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>;
+71 -40
View File
@@ -17,81 +17,112 @@ export enum Events {
ListingsUpdated = 'ListingsUpdated', ListingsUpdated = 'ListingsUpdated',
OffersUpdated = 'OffersUpdated', OffersUpdated = 'OffersUpdated',
WantsUpdated = 'WantsUpdated', WantsUpdated = 'WantsUpdated',
DiscordMessage = 'DiscordMessage',
} }
export default function createEvents(log: Log, pg: Pg, discogs, offers) { export default function createEvents(log: Log, pg: Pg, discogs, offers, discord) {
const events = new EventEmitter({ captureRejections: true }); const events = new EventEmitter({ captureRejections: true });
const connections = new Map<UUID, Rpc>(); const connections = new Map<UUID, Rpc>();
async function populateListings(want: Want) {
try {
const listings = await discogs.listings(want);
return listings;
} catch (e) {
if (e.message === 'rate limited') {
log.warn('rate limited, trying again in 60s');
await setTimeout(60 * 1000);
return populateListings(want);
}
throw e;
}
}
events.on('error', function eventError(e) { events.on('error', function eventError(e) {
log.error('event error', 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) {
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++);
}
throw e;
}
}
async function updateWantListings(wants: Want[]) { async function updateWantListings(wants: Want[]) {
for (const want of wants) { for (const want of wants) {
const listings = await populateListings(want); const listings = await populateListings(want);
log.info({ listings: listings.length, ...wantDetails(want) }, 'listings found'); log.info({ listings: listings.length, ...wantDetails(want) }, 'listings found');
events.emit(Events.ListingsUpdated); await offers.generate();
await notifyUser(want.userId);
} }
log.info({ wants: wants.length }, 'finished syncing'); 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[]) { events.on(Events.WantsAdded, async function wantsAdded(wants: Want[]) {
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population'); log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population');
await updateWantListings(wants); await updateWantListings(wants);
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings'); log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings');
events.emit(Events.ListingsUpdated);
}); });
events.on(Events.Sync, async function sync() { events.on(Events.Sync, async function sync() {
const wants = await pg.wantsToSync(); try {
log.info({ event: Events.Sync, wants: wants.length }); const wants = await pg.wantsToSync();
await updateWantListings(wants); log.info({ event: Events.Sync, wants: wants.length });
events.emit(Events.ListingsUpdated); await updateWantListings(wants);
});
events.on(Events.ListingsUpdated, async function listingsUpdated() { if (wants.length === 0) {
const updatedOffers = await offers.generate() log.info('all listings up to date');
log.debug({ offers: updatedOffers.length }, 'offers up to date'); await setTimeout(10000)
// forever
for (const [id, rpc] of connections) { events.emit(Events.Sync);
rpc.pushUserOffers(); }
} catch (e) {
log.error(e, 'sync error')
} }
}); });
// 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 }) { events.on(Events.Connection, async function onConnection({ rpc, id }) {
log.debug('new rpc connection', { id, rpc }); log.debug('new rpc connection', { id, rpc });
connections.set(id, rpc); connections.set(id, rpc);
}) })
events.on(Events.OffersUpdated, async function onOffersUpdated(offer: Offer) { events.on(Events.DiscordMessage, async function onDiscordMessage(msg) {
const rpc = connections.get(offer.userId); discord.message(msg);
if (!rpc) return null; })
log.debug({ event: Events.OffersUpdated, rpc, offer });
return rpc.pushUserOffers();
});
events.on(Events.WantsUpdated, async function onWantsUpdated(want: Want) { // emitted by listen/notify
const rpc = connections.get(want.userId); // disabled for now
if (!rpc) return null; // events.on(Events.OffersUpdated, async function onOffersUpdated(offer: Offer) {
log.debug({ event: Events.WantsUpdated, rpc, want }); // const rpc = connections.get(offer.userId);
return rpc.pushUserWants(); // 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; return events;
} }
+10 -9
View File
@@ -63,19 +63,20 @@ export default function createOffers(log: Log, pg: Pg) {
// check the user actually has this offer // check the user actually has this offer
const offer = await pg.userOfferFind(ctx.state.user, id); 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, id: UUID, status: Status) { async function statusSet(user: User, ids: UUID[], status: Status) {
log.info({ status, id }, 'setting offer status'); log.info({ status, ids }, 'setting offer status');
const offer = await pg.offerFindId(id); // const offer = await pg.offerFindId(ids);
const update = await pg.offerStatusSet(offer.id, status); const update = await pg.offerStatusSet(ids, status);
if (status === Status.Complete) { // forget about this for now
log.info({ id, status }, 'completed offer, removing want...'); // if (status === Status.Complete) {
await pg.wantRm(offer.wantId); // log.info({ id, status }, 'completed offer, removing want...');
} // await pg.wantRm(offer.wantId);
// }
return update; return update;
} }
+24 -9
View File
@@ -5,6 +5,7 @@ import * as Bluebird from 'bluebird';
import _ from 'lodash'; import _ from 'lodash';
import { Status } from './offers'; import { Status } from './offers';
import { wantDetails } from './utils';
export interface UserCreateParams { export interface UserCreateParams {
email: string, email: string,
@@ -21,11 +22,12 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
async function userWants(user: User) { async function userWants(user: User) {
return prisma.want.findMany({ const wants = await prisma.want.findMany({
where: { userId: user.id }, where: { userId: user.id },
include: { _count: { select: { offers: true }} }, include: { _count: { select: { offers: true }} },
orderBy: { offers: { _count: 'desc' } } })
});
return _.sortBy(wants, w => `${w.data.artists_sort} - ${w.data.title}`);
} }
async function userOffers(user: User) { async function userOffers(user: User) {
@@ -111,7 +113,12 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
async function wantsToSync() { async function wantsToSync() {
return prisma.want.findMany({}); return prisma.want.findMany({
orderBy: { updatedAt: 'asc' },
where: {
updatedAt: { lte: new Date(Date.now() - 1000 * 60 * 15) }
}
});
} }
async function wantRm(id: UUID) { async function wantRm(id: UUID) {
@@ -132,6 +139,13 @@ 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[]) { async function listingRmUnlisted(want: Want, currentListingIds: DiscogsListing[]) {
return prisma.$transaction(async (prisma) => { return prisma.$transaction(async (prisma) => {
log.debug({ currentListingIds }); log.debug({ currentListingIds });
@@ -152,7 +166,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
}); });
if (offersRemoved > 0) log.info({ offersRemoved }, 'pending offers removed'); if (offersRemoved > 0) log.info({ offersRemoved, ...wantDetails(want) }, 'pending offers removed');
// now we have to filter listingIds with confirmed offers // now we have to filter listingIds with confirmed offers
// and get their listingIds to filter out // and get their listingIds to filter out
@@ -179,7 +193,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
}, },
}); });
log.info({ count }, 'second stage open offers removed'); if (count) log.info({ count }, 'second stage open offers removed');
return count; return count;
}); });
@@ -275,9 +289,9 @@ export default function createPg(log: Log, prisma: PrismaClient) {
); );
} }
async function offerStatusSet(id: UUID, status: Status) { async function offerStatusSet(ids: UUID[], status: Status) {
return prisma.offer.update({ return prisma.offer.updateMany({
where: { id: id }, where: { id: { in: ids } },
data: { status }, data: { status },
}); });
} }
@@ -304,6 +318,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
wantCreate, wantCreate,
wantList, wantList,
wantsToSync, wantsToSync,
wantSynced,
wantRm, wantRm,
listingUpsert, listingUpsert,
+3 -1
View File
@@ -4,8 +4,9 @@ import { Strategy as LocalStrategy } from 'passport-local';
import Router from '@koa/router' import Router from '@koa/router'
import type { Pg } from './../pg'; import type { Pg } from './../pg';
import { Events } from './../events';
export default function authRouter(log, pg: Pg) { export default function authRouter(log, pg: Pg, events) {
const passport = new KoaPassport(); const passport = new KoaPassport();
async function verifyPassword(user, password) { async function verifyPassword(user, password) {
@@ -69,6 +70,7 @@ export default function authRouter(log, pg: Pg) {
id: user.id, id: user.id,
email: user.email, email: user.email,
}; };
events.emit(Events.DiscordMessage, { msg: 'user register', email: user.email });
}); });
return { authRouter, passport }; return { authRouter, passport };
+6 -4
View File
@@ -75,7 +75,7 @@ export type RpcMessage =
type OfferStatusSetParams = { type OfferStatusSetParams = {
id: UUID, ids: UUID[],
status: Status, status: Status,
}; };
@@ -109,13 +109,15 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
} }
async function offerStatusSet(params: OfferStatusSetParams): Promise<OffersMessage> { async function offerStatusSet(params: OfferStatusSetParams): Promise<OffersMessage> {
const { id, status } = params; const { ids, status } = params;
assert(id, 'no id supplied'); assert(ids, 'no ids supplied');
assert(Status[status], 'unknown status'); assert(Status[status], 'unknown status');
const offer = await offers.statusSet(user, id, status); const offer = await offers.statusSet(user, ids, status);
const userOffers = await offers.userOffers(user); const userOffers = await offers.userOffers(user);
events.emit(Events.DiscordMessage, { msg: 'user offer event', user: user.email, offer })
return [RpcMessageType.Offers, userOffers] return [RpcMessageType.Offers, userOffers]
} }
+10 -7
View File
@@ -1,25 +1,28 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import createDiscogs from './discogs';
import createOffers from './offers';
import createLog from './log';
import createApi from './api'; import createApi from './api';
import createDiscogs from './discogs';
import createEvents from './events'; import createEvents from './events';
import createPg from './pg'; import createLog from './log';
import createNotify from './notify'; import createNotify from './notify';
import createOffers from './offers';
import createPg from './pg';
import createDiscord from './discord';
export default function createServices(log) { export default async function createServices(log) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const discord = createDiscord(log);
const pg = createPg(log, prisma); const pg = createPg(log, prisma);
const discogs = createDiscogs(log, pg); const discogs = await createDiscogs(log, pg);
const offers = createOffers(log, pg); const offers = createOffers(log, pg);
const events = createEvents(log, pg, discogs, offers); const events = createEvents(log, pg, discogs, offers, discord);
const notify = createNotify(log, pg, events); const notify = createNotify(log, pg, events);
const api = createApi(log, pg, discogs, offers, events); const api = createApi(log, pg, discogs, offers, events);
return { return {
api, api,
discogs, discogs,
discord,
events, events,
notify, notify,
pg, pg,
+2
View File
@@ -17,6 +17,8 @@ export function logAxiosError(e) {
export function wantDetails(want: Want) { export function wantDetails(want: Want) {
const data = want.data as any; const data = want.data as any;
if (!data) console.warn(want);
return { return {
title: data.title, title: data.title,
release: data.id, release: data.id,
+11 -7
View File
@@ -8,23 +8,27 @@ module.exports = {
"build/" "build/"
], ],
rules: { rules: {
'arrow-body-style': [0],
'max-len': ['error', 120],
'no-console': [0],
'no-multi-spaces': [0],
// prevents stupid complaints a la // prevents stupid complaints a la
// (req) { // (req) {
// req.something = x; // req.something = x;
// } // }
'no-param-reassign': [2, { props: false }], 'no-param-reassign': [2, { props: false }],
'no-multi-spaces': [0],
'max-len': ['error', 120],
'import/no-extraneous-dependencies': [0],
'prefer-arrow-callback': [0],
'arrow-body-style': [0],
'no-return-assign': 0, 'no-return-assign': 0,
'no-console': [0],
'prefer-arrow-callback': [0],
// i like loops // i like loops
'no-plusplus': [0], 'no-plusplus': [0],
'no-await-in-loop': [0], 'no-await-in-loop': [0],
indent: ['error', 4], indent: ['error', 4],
'keyword-spacing': ['error'], 'keyword-spacing': ['error'],
'key-spacing': ['error'], 'key-spacing': ['error'],
}, },
+12
View File
@@ -0,0 +1,12 @@
# 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
+8
View File
@@ -0,0 +1,8 @@
# build step
FROM node:16 AS build-env
WORKDIR /opt/crates/client
COPY . ./
RUN npm i
CMD ["npm", "start"]
+49
View File
@@ -0,0 +1,49 @@
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 /;
}
}
Generated Regular → Executable
+14
View File
@@ -22,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-react-jsx-source": "^7.18.6", "@babel/plugin-transform-react-jsx-source": "^7.18.6",
"@types/lodash": "^4.17.10",
"@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6", "@typescript-eslint/parser": "^5.30.6",
"eslint": "^8.20.0", "eslint": "^8.20.0",
@@ -3009,6 +3010,13 @@
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true "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": { "node_modules/@types/mime": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz",
@@ -23437,6 +23445,12 @@
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true "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": { "@types/mime": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz",
Regular → Executable
+2 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "preact build --no-prerender", "build": "preact build --no-prerender",
"start": "preact --no-clear watch", "start": "preact --no-clear watch",
"lint": "eslint src" "lint": "eslint --fix src"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.5", "@reduxjs/toolkit": "^1.8.5",
@@ -22,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-react-jsx-source": "^7.18.6", "@babel/plugin-transform-react-jsx-source": "^7.18.6",
"@types/lodash": "^4.17.10",
"@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6", "@typescript-eslint/parser": "^5.30.6",
"eslint": "^8.20.0", "eslint": "^8.20.0",
+10 -2
View File
@@ -16,7 +16,15 @@ import Search from './routes/search';
import List from './routes/list'; import List from './routes/list';
import Info from './routes/info'; import Info from './routes/info';
if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates'); if (window.location.host === 'crates.ntwl.xyz') LogRocket.init('yh0dy3/mcrates');
// ██ ██ ███████ █████ ██████ ███████ ███████ ██████ ██████ █████ ██████ ██ ██
// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
// ██ █ ██ █████ ███████ ██████ █████ ███████ ██ ██ ██████ ███████ ██ █████
// ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
// ███ ███ ███████ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ██ ██ ██████ ██ ██
const Mcrates = () => { const Mcrates = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -46,7 +54,7 @@ const Mcrates = () => {
<Route path="/register" component={Login} mode="register" /> <Route path="/register" component={Login} mode="register" />
<Route path="/search" component={Search} /> <Route path="/search" component={Search} />
<Route path="/list" component={List} /> <Route path="/list" component={List} />
<Route path="/info" component={Info} /> {/*<Route path="/info" component={Info} />*/}
{/* ACP */} {/* ACP */}
<Route path="/acp" component={Acp} /> <Route path="/acp" component={Acp} />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

-81
View File
@@ -1,81 +0,0 @@
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;
-84
View File
@@ -1,84 +0,0 @@
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';
const title = accepted
? 'Click to cancel'
: 'Click to accept this offer';
return (
<button disabled={confirmed} title={title} onClick={() => setStatus()} class={classes}>${offer.total} {offer.condition}</button>
)
}
export default function OfferGroup(group, i) {
const ws = useSelector((state: State) => state.ws);
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}`;
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"><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 class="lines">
{group.offers.map(o => OfferPriceBtn(o))}
</div>
</div>
)
}
+2 -2
View File
@@ -15,8 +15,8 @@ declare module 'axios' {
} }
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true withCredentials: true
}); });
function onSuccess(response) { function onSuccess(response) {
+4 -4
View File
@@ -34,11 +34,11 @@ function Item({ item }) {
return ( return (
<div key={item.id} class={bold}> <div key={item.id} class={bold}>
<a href={item.listing.data.uri} target="_blank" > <a href={item.listing.data.uri} target="_blank" rel="noreferrer" >
<h4>{item.listing.data.release.description}</h4> <h4>{item.listing.data.release.description}</h4>
</a> </a>
<h5>{item.user.email}</h5> <h5>{item.user.email}</h5>
<h5 class='alt-text'>{item.total}</h5> <h5 class='alt-text'>{item.total} - {item.condition}</h5>
<h5>{item.status}</h5> <h5>{item.status}</h5>
<label>Set Status:</label> <label>Set Status:</label>
<select onChange={onChange}> <select onChange={onChange}>
@@ -53,7 +53,7 @@ function SellerGroup(group) {
return ( return (
<div class="bundle"> <div class="bundle">
<section> <section>
<h2>{group.username} - {group.seller}</h2> <h2>{group.username} - {group.seller}</h2>
<h3>{ready} / {group.offers.length} offers</h3> <h3>{ready} / {group.offers.length} offers</h3>
<h4>Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}</h4> <h4>Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}</h4>
@@ -75,7 +75,7 @@ function Home() {
} }
return ( return (
<div> <div class='acp'>
<button onClick={sync}> sync listings </button> <button onClick={sync}> sync listings </button>
<button onClick={sync}> generate offers </button> <button onClick={sync}> generate offers </button>
<hr /> <hr />
+1
View File
@@ -18,6 +18,7 @@ const Info = () => {
<h2>How It Works</h2> <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> 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> 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> 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> <p> Check back regularly for updated <span class='alt'>Offers</span>, the global record marketplace is a living thing. </p>
</section> </section>
+4 -4
View File
@@ -93,7 +93,7 @@ const Login = ({ mode }) => {
<input type="password" name="password" id="password" /> <input type="password" name="password" id="password" />
</div> </div>
<div class="item"> <div class="item">
<input type="submit" value="login"/> <input type="submit" value="login" />
</div> </div>
</form> </form>
<button onClick={gotoRegister}> create account </button> <button onClick={gotoRegister}> create account </button>
@@ -107,9 +107,9 @@ function LoginPage({ mode }) {
<Login mode={mode} /> <Login mode={mode} />
<Swirl /> <Swirl />
<div class='words'> <div class='words'>
<p>Mates Crates is a record importing service.</p> <p>record importing service</p>
<p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p> <p>we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl</p>
<p>Records can then be picked up from Preston.</p> <p>then we get together and play them</p>
</div> </div>
</div> </div>
) )
+3 -1
View File
@@ -8,7 +8,9 @@ import * as toast from './../toast';
import OfferGroup from './../components/offer.group'; import OfferGroup from './../components/offer.group';
const GroupNav = (want) => { const GroupNav = (want) => {
const href = want._count.offers ? `#${want.release}` : null; if (!want._count.offers) return null
const href = `#${want.release}`;
return ( return (
<div class=''> <div class=''>
<a href={href}>{want.data.artists_sort} - {want.data.title}</a> <a href={href}>{want.data.artists_sort} - {want.data.title}</a>
+18 -14
View File
@@ -4,6 +4,7 @@ import { ExternalLink, Check } from 'react-feather';
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { State } from './../store'; import { State } from './../store';
import { info, error as toastError } from './../toast';
import http from '../http'; import http from '../http';
@@ -24,7 +25,7 @@ function Result(r, wants, ws) {
<article key={r.id}> <article key={r.id}>
<button onClick={onClick} disabled={wanted} class={classes} >{btnText}</button> <button onClick={onClick} disabled={wanted} class={classes} >{btnText}</button>
<div> <div>
<h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank"><ExternalLink /></a></h3> <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.year}</h5>
<h5 class='alt-text'>{r.country}</h5> <h5 class='alt-text'>{r.country}</h5>
<h5 class='alt-text'>{r.format.join(' ')}</h5> <h5 class='alt-text'>{r.format.join(' ')}</h5>
@@ -36,10 +37,8 @@ function Result(r, wants, ws) {
const User = () => { const User = () => {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [title, setTitle] = useState('');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [infoText, setInfoText] = useState('');
const wants = useSelector((state: State) => state.wants); const wants = useSelector((state: State) => state.wants);
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
@@ -47,23 +46,33 @@ const User = () => {
async function fetchResults() { async function fetchResults() {
const [error, results] = await http.postWantSearch(query); const [error, results] = await http.postWantSearch(query);
if (results.length === 0) { if (error) {
setResults([]); setResults([]);
return setInfoText('No results'); toastError('Error', error);
}
if (results.length === 0) {
setResults([]);
return info('No Results', 'No results returned from search');
} }
setInfoText('');
setResults(results); setResults(results);
} }
async function submitWantlist() { async function submitWantlist() {
const [error, wants] = await http.postDiscogs(username); const [error, wants] = await http.postDiscogs(username);
if (wants.length === 0) {
if (error) {
setResults([]); setResults([]);
return setInfoText('No new additions'); toastError('Error', error);
} }
setInfoText(`Added ${wants.length} items to your list.`); 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 searchRef = useRef<HTMLInputElement>();
@@ -87,10 +96,6 @@ const User = () => {
? <button onClick={() => resultsRef.current.scrollTo(0, 0)}> top </button> ? <button onClick={() => resultsRef.current.scrollTo(0, 0)}> top </button>
: null; : null;
const infoEl = infoText
? <article><div></div><div>{infoText}</div></article>
: null;
return ( return (
<div class='search'> <div class='search'>
<div> <div>
@@ -111,7 +116,6 @@ const User = () => {
<p>Data provided by Discogs.</p> <p>Data provided by Discogs.</p>
</div> </div>
<div class='results' ref={resultsRef}> <div class='results' ref={resultsRef}>
{infoEl}
{results.map(r => Result(r, wants, ws))} {results.map(r => Result(r, wants, ws))}
{topEl} {topEl}
</div> </div>
+33 -33
View File
@@ -2,58 +2,58 @@ import { createStore, combineReducers } from 'redux'
import { configureStore, createSlice } from '@reduxjs/toolkit' import { configureStore, createSlice } from '@reduxjs/toolkit'
export const userState = createSlice({ export const userState = createSlice({
name: 'user', name: 'user',
initialState: null, initialState: null,
reducers: { reducers: {
set: (state, action) => action.payload set: (state, action) => action.payload
}, },
}); });
export const offersState = createSlice({ export const offersState = createSlice({
name: 'offers', name: 'offers',
initialState: null, initialState: null,
reducers: { reducers: {
set: (state, action) => action.payload set: (state, action) => action.payload
}, },
}); });
export const acpOffersState = createSlice({ export const acpOffersState = createSlice({
name: 'acpOffers', name: 'acpOffers',
initialState: [], initialState: [],
reducers: { reducers: {
set: (state, action) => action.payload set: (state, action) => action.payload
}, },
}); });
export const wantsState = createSlice({ export const wantsState = createSlice({
name: 'wants', name: 'wants',
initialState: null, initialState: null,
reducers: { reducers: {
set: (state, action) => action.payload set: (state, action) => action.payload
}, },
}); });
export const wsState = createSlice({ export const wsState = createSlice({
name: 'ws', name: 'ws',
initialState: null, initialState: null,
reducers: { reducers: {
set: (state, action) => action.payload set: (state, action) => action.payload
}, },
}); });
const reducer = combineReducers({ const reducer = combineReducers({
offers: offersState.reducer, offers: offersState.reducer,
wants: wantsState.reducer, wants: wantsState.reducer,
ws: wsState.reducer, ws: wsState.reducer,
user: userState.reducer, user: userState.reducer,
acpOffers: acpOffersState.reducer, acpOffers: acpOffersState.reducer,
}); });
const store = createStore( const store = createStore(
reducer, reducer,
// @ts-ignore -- devtools window exists on dev // @ts-ignore -- devtools window exists on dev
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
); );
export default store; export default store;
+1
View File
@@ -75,6 +75,7 @@ html {
--color-blue: #475977; --color-blue: #475977;
--color-green: #476d4a; --color-green: #476d4a;
--color-red: #ae4442;
--color-accepted: var(--color-blue); --color-accepted: var(--color-blue);
--color-confirmed: var(--color-green); --color-confirmed: var(--color-green);
+28 -16
View File
@@ -22,6 +22,8 @@ body {
.landing { .landing {
display: grid; display: grid;
overflow: scroll;
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr;
grid-gap: calc(var(--spacing-double) * 2); grid-gap: calc(var(--spacing-double) * 2);
grid-template-areas: grid-template-areas:
@@ -49,6 +51,8 @@ body {
.info { .info {
display: grid; display: grid;
overflow: hidden;
grid-template-areas: grid-template-areas:
"words swirl" "words swirl"
"words contact"; "words contact";
@@ -63,10 +67,12 @@ body {
.contact { .contact {
grid-area: contact; grid-area: contact;
text-align: center; text-align: center;
overflow: scroll;
} }
.words { .words {
grid-area: words; grid-area: words;
overflow: scroll;
} }
.swirl { .swirl {
@@ -236,29 +242,33 @@ main {
} }
} }
.bundles { .acp {
flex-flow: row; overflow: scroll;
.bundle { .bundles {
margin-bottom: var(--spacing-double); flex-flow: row;
section { .bundle {
margin-bottom: var(--spacing); margin-bottom: var(--spacing-double);
}
.items { section {
display: grid;
grid-template-columns: repeat(5, 1fr);
div {
margin-right: var(--spacing);
margin-bottom: var(--spacing); margin-bottom: var(--spacing);
}
.items {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr);
&.bold { div {
font-weight: bold; margin-right: var(--spacing);
background: var(--background-2); margin-bottom: var(--spacing);
display: grid;
&.bold {
font-weight: bold;
background: var(--background-2);
}
} }
} }
} }
@@ -340,6 +350,8 @@ main {
margin-bottom: var(--spacing); margin-bottom: var(--spacing);
} }
input[type='range']{ accent-color: var(--color-accepted); }
@media (max-width:900px) { @media (max-width:900px) {
.landing { .landing {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>matescrates.au - record import service</title> <title>crates - record import service</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
+15
View File
@@ -13,3 +13,18 @@ export function info(title, message) {
theme: 'dark', 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',
});
}
+9 -3
View File
@@ -1,4 +1,4 @@
import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle';
import { wantsState, userState, offersState, acpOffersState } from './store' import { wantsState, userState, offersState, acpOffersState } from './store'
import * as toast from './toast'; import * as toast from './toast';
@@ -29,8 +29,8 @@ export default function createSocket(dispatch) {
// setTimeout(sendPing, 50); // setTimeout(sendPing, 50);
} }
function sendOfferStatusSet(id, status) { function sendOfferStatusSet(ids, status) {
send(['OfferStatusSet', { id, status }]); send(['OfferStatusSet', { ids, status }]);
} }
function sendWantRm(id) { function sendWantRm(id) {
@@ -77,7 +77,13 @@ export default function createSocket(dispatch) {
dispatch(wantsState.actions.set(wants)); 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) { function onOffers(offers) {
offersNotification(offers);
dispatch(offersState.actions.set(offers)); dispatch(offersState.actions.set(offers));
} }
+31
View File
@@ -0,0 +1,31 @@
{
"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": ".",
}
],
}
+11
View File
@@ -0,0 +1,11 @@
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# Fresh build directory
_fresh/
# npm dependencies
node_modules/
+3
View File
@@ -0,0 +1,3 @@
- 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
+16
View File
@@ -0,0 +1,16 @@
# 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.
+80
View File
@@ -0,0 +1,80 @@
// 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"
}
}
+112
View File
@@ -0,0 +1,112 @@
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()
}
+34
View File
@@ -0,0 +1,34 @@
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" }
}
)
}
}
+25
View File
@@ -0,0 +1,25 @@
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()
}
+154
View File
@@ -0,0 +1,154 @@
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>;
+92
View File
@@ -0,0 +1,92 @@
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>
+6
View File
@@ -0,0 +1,6 @@
function Ok(headers) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers
})
}
+34
View File
@@ -0,0 +1,34 @@
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() {}
+89
View File
@@ -0,0 +1,89 @@
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;
+56
View File
@@ -0,0 +1,56 @@
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;
+30
View File
@@ -0,0 +1,30 @@
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;
+118
View File
@@ -0,0 +1,118 @@
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;
+53
View File
@@ -0,0 +1,53 @@
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;
+45
View File
@@ -0,0 +1,45 @@
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;
+105
View File
@@ -0,0 +1,105 @@
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,21 +1,9 @@
import { h } from 'preact'; import { h } from 'preact';
import { ExternalLink, Check } from 'react-feather'; import { ExternalLink, Check } from 'lucide-preact';
import { useState } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux'
import { State } from './../store';
import http from '../http';
export default function Offer(offer) { export default function Offer(offer) {
const ws = useSelector((state: State) => state.ws);
const { want: { data: details } } = offer; const { want: { data: details } } = offer;
function setStatus(status) {
ws.sendOfferStatusSet(offer.id, status);
}
const accepted = offer.status === 'Accepted'; const accepted = offer.status === 'Accepted';
const classes = `${accepted ? 'accepted' : ''}`; const classes = `${accepted ? 'accepted' : ''}`;
const btnText = accepted const btnText = accepted
@@ -23,21 +11,21 @@ export default function Offer(offer) {
: 'Accept'; : 'Accept';
const negBtn = accepted const negBtn = accepted
? <button onClick={() => setStatus('Pending')} class='neg'>Cancel</button> ? <button disabled={true} class='neg'>Cancel</button>
: <button onClick={() => setStatus('Declined')} class='neg'>Decline</button>; : <button disabled={true} class='neg'>Decline</button>;
return ( return (
<div class="offer"> <div class="offer">
<section> <section>
<h3>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h3> <h3>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3>
<div class='alt-text' >{details.year}</div> <div class='alt-text' >{details.year}</div>
<div class='alt-text'>{details.formats[0].descriptions.join(' ')}</div> <div class='alt-text'>{details.formats[0].descriptions.join(' ')}</div>
<div class='number'>mcrates price: ${offer.total}</div> <div class='number'>mcrates price: ${offer.total}</div>
<div class='number'>shipped by itself: ${offer.comparison}</div> <div class='number'>shipped by itself: ${offer.comparison}</div>
</section> </section>
<img src={details.thumb} /> <img src={details.thumb} />
<button onClick={() => setStatus('Accepted')} disabled={accepted} class={classes} >{btnText}</button> <button disabled={true} class={classes}>{btnText}</button>
{negBtn} {negBtn}
</div> </div>
) )
} }
+126
View File
@@ -0,0 +1,126 @@
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;
+87
View File
@@ -0,0 +1,87 @@
/* 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;
}
+194
View File
@@ -0,0 +1,194 @@
// 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';
+422
View File
@@ -0,0 +1,422 @@
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;
}
}
File renamed without changes.
@@ -27,7 +27,7 @@ function Want(want) {
return ( return (
<article key={r.id}> <article key={r.id}>
<div> <div>
<h3>{r.artists_sort} - {r.title} <a href={r.uri} target="_blank"><ExternalLink /></a></h3> <h3>{r.artists_sort} - {r.title} <a href={r.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3>
</div> </div>
<img src={r.thumb} /> <img src={r.thumb} />
<div class='details'> <div class='details'>
+30
View File
@@ -0,0 +1,30 @@
{
"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": ".",
}
],
}
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
{
"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"
}
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/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);
+14
View File
@@ -0,0 +1,14 @@
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()
],
});
+43
View File
@@ -0,0 +1,43 @@
// 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
+51
View File
@@ -0,0 +1,51 @@
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");
},
};
}
+13
View File
@@ -0,0 +1,13 @@
/// <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);
+608
View File
@@ -0,0 +1,608 @@
{
"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
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"devDependencies": {
"prisma": "^6.6.0"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"lodash": "^4.17.21",
"zod": "^3.24.3",
"zodix": "^0.4.4"
}
}
@@ -1,8 +1,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
// include for node docker image output = "./generated"
binaryTargets = ["native", "debian-openssl-1.1.x"] moduleFormat = "esm"
previewFeatures = ["interactiveTransactions"]
} }
datasource db { datasource db {
@@ -26,7 +25,7 @@ model Listing {
offers Offer[] offers Offer[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @default(now())
@@index([seller]) @@index([seller])
@@map("listings") @@map("listings")
@@ -38,7 +37,7 @@ model User {
password String password String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @default(now())
wants Want[] wants Want[]
offers Offer[] offers Offer[]
@@ -55,7 +54,7 @@ model Want {
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @default(now())
listings Listing[] listings Listing[]
offers Offer[] offers Offer[]
+27
View File
@@ -0,0 +1,27 @@
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>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
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>
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
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>
);
}
+5
View File
@@ -0,0 +1,5 @@
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]
+52
View File
@@ -0,0 +1,52 @@
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" }
});
},
}
+32
View File
@@ -0,0 +1,32 @@
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
})
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Handlers } from "$fresh/server.ts"
import { setCookie } from "$std/http/cookie.ts"
import { z } from "npm:zod"
import { zx } from "npm:zodix"
import { createPg } from "api/pg.ts"
import { createSession } from "api/mw.auth.ts"
import { ResourceExistsError } from "api/errors.ts"
import { CratesState } from 'api/mw.service.ts'
// Define validation schema with Zod
const RegisterSchema = z.object({
email: z.string().email("Invalid email format"),
password: z.string().min(6, "Password must be at least 6 characters"),
discogs: z.string().optional()
})
export const handler: Handlers<{email: string}, CratesState> = {
async POST(req, _ctx) {
const { discogs, email, password } = await zx.parseForm(req, RegisterSchema)
// Create pg instance for database operations
const pg = createPg(console)
// Check if user exists
const existingUser = await pg.userFindEmail(email)
if (existingUser) throw new ResourceExistsError("User with this email")
// Create user
const params = { discogs, email, password, data: {} }
const user = await pg.userCreate(params)
// 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 user data
return new Response(JSON.stringify({
id: user.id,
email: user.email
}), {
status: 200,
headers
})
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Handlers } from "$fresh/server.ts"
export const handler: Handlers = {
async GET(_req, ctx) {
const user = ctx.state.user
return new Response(JSON.stringify({
authenticated: true,
user: {
id: user.id,
email: user.email
}
}), {
status: 200,
headers: { "Content-Type": "application/json" }
})
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Handlers } from "$fresh/server.ts"
import * as offersService from "api/offers.ts"
export const handler: Handlers = {
async GET(_req, ctx) {
const user = ctx.state.user
const offers = await offersService.userOffers(user)
return new Response(JSON.stringify(offers), {
status: 200,
headers: { "Content-Type": "application/json" }
})
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Handlers } from "$fresh/server.ts"
import { createPg } from "../../api/pg.ts"
export const handler: Handlers = {
async GET(_req, ctx) {
const user = ctx.state.user
const pg = createPg(console)
const wants = await pg.userWants(user)
return new Response(JSON.stringify(wants), {
status: 200,
headers: { "Content-Type": "application/json" }
})
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import Main from "../components/main.tsx";
import { CratesState } from "api/mw.service.ts"
import { Offer, Want } from "prismaClient"
import Nav from '../components/nav.tsx'
interface HomeData {
wants: Want[],
offers: Offer[],
}
export const handler: Handlers<HomeData, CratesState> = {
async GET(_req, ctx) {
const { pg, offers, user } = ctx.state
const wants = await pg.userWants(user);
const uOffers = await offers.userOffers(user);
return await ctx.render({ user, wants, offers: uOffers });
},
};
export default function Home({ data }: PageProps) {
const { user, wants, offers } = data
return (
<>
<Nav user={user} />
<Main wants={wants} offers={offers} />
</>
);
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more