5 Commits
Author SHA1 Message Date
ntr 5b0c7c05ca navbar and notifications 2023-01-17 23:44:16 +11:00
ntr a97560c1c7 fix search and list to work with overflow scrolling 2023-01-15 12:24:15 +11:00
ntr 00cb224fb3 font change 2023-01-15 12:07:47 +11:00
ntr 6c3cac7381 navbar wip 2023-01-11 23:22:15 +11:00
ntr b91f877045 notify 2022-12-27 15:37:46 +11:00
24 changed files with 738 additions and 576 deletions

No files matched your search

+2 -5
View File
@@ -1,15 +1,12 @@
# TODO # TODO
push offers push offers
listen / notify add last synced time to want
on offers
notify admins
go over and reorganise event logic
contact options on landing page contact options on landing page
move want add to rpc
skip unofficial release skip unofficial release
status stepper status stepper
+1 -1
View File
@@ -29,7 +29,7 @@ async function start() {
notify, notify,
} = createServices(log); } = createServices(log);
await notify.connect(); await notify.listen();
api.listen(); api.listen();
} }
+5
View File
@@ -11,6 +11,11 @@ datasource db {
} }
model Listing { model Listing {
// example listingId 2148851036
// works fine as a JS number
// but JS numbers are cast to int4 for pg by prisma
// becomes -2146116260
id BigInt @id id BigInt @id
seller BigInt seller BigInt
data Json data Json
-10
View File
@@ -63,16 +63,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
ctx.status = 200; ctx.status = 200;
}); });
router.post('/want', async function wantPost(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { releaseId } = ctx.request.body;
const user = await pg.userFindId(ctx.state.user.id);
const release = await discogs.release(releaseId);
const want = await pg.wantCreate(release, user);
events.emit(Events.WantsAdded, [want]);
ctx.response.body = [want];
});
router.post('/want/discogs', async function wantListPost(ctx, next) { 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;
+1 -1
View File
@@ -48,7 +48,7 @@ export default function createDiscogs(log: Log, pg: Pg) {
// @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);
log.info('want list additions', additions); log.info('want list additions', { additions: additions.length });
return additions; return additions;
} }
+16 -4
View File
@@ -12,10 +12,11 @@ import routerAuth from './routers/router.auth';
export enum Events { export enum Events {
WantsAdded = 'WantsAdded', WantsAdded = 'WantsAdded',
Sync = 'Sync', Sync = 'Sync',
ListingsUpdated = 'ListingsUpdated',
Connection = 'Connection', Connection = 'Connection',
OffersUpdated = 'OffersUpdated' ListingsUpdated = 'ListingsUpdated',
OffersUpdated = 'OffersUpdated',
WantsUpdated = 'WantsUpdated',
} }
export default function createEvents(log: Log, pg: Pg, discogs, offers) { export default function createEvents(log: Log, pg: Pg, discogs, offers) {
@@ -43,7 +44,7 @@ export default function createEvents(log: Log, pg: Pg, discogs, offers) {
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 added'); log.info({ listings: listings.length, ...wantDetails(want) }, 'listings found');
events.emit(Events.ListingsUpdated); events.emit(Events.ListingsUpdated);
} }
@@ -67,6 +68,10 @@ export default function createEvents(log: Log, pg: Pg, discogs, offers) {
events.on(Events.ListingsUpdated, async function listingsUpdated() { events.on(Events.ListingsUpdated, async function listingsUpdated() {
const updatedOffers = await offers.generate() const updatedOffers = await offers.generate()
log.debug({ offers: updatedOffers.length }, 'offers up to date'); log.debug({ offers: updatedOffers.length }, 'offers up to date');
for (const [id, rpc] of connections) {
rpc.pushUserOffers();
}
}); });
events.on(Events.Connection, async function onConnection({ rpc, id }) { events.on(Events.Connection, async function onConnection({ rpc, id }) {
@@ -78,7 +83,14 @@ export default function createEvents(log: Log, pg: Pg, discogs, offers) {
const rpc = connections.get(offer.userId); const rpc = connections.get(offer.userId);
if (!rpc) return null; if (!rpc) return null;
log.debug({ event: Events.OffersUpdated, rpc, offer }); log.debug({ event: Events.OffersUpdated, rpc, offer });
return rpc.sendUserOffers(); 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;
-20
View File
@@ -1,20 +0,0 @@
import createSubscriber from 'pg-listen'
import type Log from 'bunyan';
import { Events } from './events';
export default function createNotify(log: Log, events) {
const CHANNEL = 'events';
const subscriber = createSubscriber({ connectionString: process.env.DATABASE_URL });
subscriber.notifications.on(CHANNEL, ({ table, action, record }) => {
if (table === 'offers') return events.emit(Events.OffersUpdated, record);
})
async function connect() {
await subscriber.connect();
return subscriber.listenTo(CHANNEL);
}
return { connect };
}
+11 -6
View File
@@ -18,22 +18,27 @@ export default function createNotify(log: Log, pg: Pg, events) {
async function handleOffers(payload) { async function handleOffers(payload) {
const { action, id } = payload; const { action, id } = payload;
log.info({ channel: OFFERS_CHANNEL, action, id }); log.debug({ channel: OFFERS_CHANNEL, action, id });
if (action === 'DELETE') return null;
// const offer = await pg.offerFindId(id);
// events.emit(Events.OffersUpdated, offer);
} }
async function handleUsers(payload) { async function handleUsers(payload) {
const { action, id } = payload; const { action, id } = payload;
log.info({ channel: USERS_CHANNEL, action, id }); log.debug({ channel: USERS_CHANNEL, action, id });
} }
async function handleListings(payload) { async function handleListings(payload) {
const { action, id } = payload; const { action, id } = payload;
log.info({ channel: LISTINGS_CHANNEL, action, id }); log.debug({ channel: LISTINGS_CHANNEL, action, id });
} }
async function handleWants(payload) { async function handleWants(payload) {
const { action, id } = payload; const { action, id } = payload;
log.info({ channel: WANTS_CHANNEL, action, id }); // const want = await pg.offerFindId(id);
log.debug({ channel: WANTS_CHANNEL, action, id });
} }
subscriber.notifications.on(OFFERS_CHANNEL, handleOffers); subscriber.notifications.on(OFFERS_CHANNEL, handleOffers);
@@ -41,7 +46,7 @@ export default function createNotify(log: Log, pg: Pg, events) {
subscriber.notifications.on(LISTINGS_CHANNEL, handleListings); subscriber.notifications.on(LISTINGS_CHANNEL, handleListings);
subscriber.notifications.on(WANTS_CHANNEL, handleWants); subscriber.notifications.on(WANTS_CHANNEL, handleWants);
async function connect() { async function listen() {
await subscriber.connect(); await subscriber.connect();
await Promise.all([ await Promise.all([
subscriber.listenTo(OFFERS_CHANNEL), subscriber.listenTo(OFFERS_CHANNEL),
@@ -51,5 +56,5 @@ export default function createNotify(log: Log, pg: Pg, events) {
]); ]);
} }
return { connect }; return { listen };
} }
+7
View File
@@ -24,6 +24,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
return prisma.want.findMany({ return 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' } }
}); });
} }
@@ -97,6 +98,12 @@ export default function createPg(log: Log, prisma: PrismaClient) {
}); });
} }
async function wantFindId(id: UUID) {
return prisma.want.findFirstOrThrow({
where: { id },
});
}
async function wantList(user: User) { async function wantList(user: User) {
return prisma.want.findMany({ return prisma.want.findMany({
where: { userId: user.id }, where: { userId: user.id },
+99 -80
View File
@@ -12,6 +12,81 @@ import { Status } from './offers';
import { isAdmin } from './utils'; import { isAdmin } from './utils';
import { Events } from './events'; import { Events } from './events';
export type Rpc = ReturnType<typeof createRpc>;
export enum RpcMessageType {
Ping = 'Ping',
Pong = 'Pong',
// points out user is anon
WhoDis = 'WhoDis',
Bundles = 'Bundles',
Offers = 'Offers',
List = 'List',
Wants = 'Wants',
User = 'User',
Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers',
AcpListingsSync = 'AcpListingsSync',
WantRm = 'WantRm',
WantAdd = 'WantAdd',
}
type ErrorMessage = [RpcMessageType.Error, string];
type PongMessage = [RpcMessageType.Pong, []];
type PingMessage = [RpcMessageType.Ping, []];
type WhoDisMessage = [RpcMessageType.WhoDis, []];
type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
export type RpcMessage =
PingMessage |
PongMessage |
WhoDisMessage |
UserMessage |
WantsMessage |
OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage |
AcpOfferStatusSetMessage |
AcpOffersMessage |
AcpListingsSyncMessage |
ErrorMessage;
type OfferStatusSetParams = {
id: UUID,
status: Status,
};
type WantRmParams = {
id: UUID,
};
type WantAddParams = {
releaseId: number,
};
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType { function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
if (RpcMessageType[msgType]) { if (RpcMessageType[msgType]) {
return true; return true;
@@ -54,6 +129,18 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
return [RpcMessageType.Wants, wants]; return [RpcMessageType.Wants, wants];
} }
async function wantAdd(params: WantAddParams): Promise<WantsMessage> {
const { releaseId } = params;
assert(releaseId, 'no id supplied');
const release = await discogs.release(releaseId);
const want = await pg.wantCreate(release, user);
events.emit(Events.WantsAdded, [want]);
const wants = await pg.userWants(user);
return [RpcMessageType.Wants, wants];
}
async function acpOfferStatusSet(params: OfferStatusSetParams): Promise<AcpOffersMessage> { async function acpOfferStatusSet(params: OfferStatusSetParams): Promise<AcpOffersMessage> {
const { id, status } = params; const { id, status } = params;
assert(isAdmin(user), 'not authorised'); assert(isAdmin(user), 'not authorised');
@@ -101,6 +188,8 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
case RpcMessageType.WantRm: case RpcMessageType.WantRm:
return wantRm(params); return wantRm(params);
case RpcMessageType.WantAdd:
return wantAdd(params);
case RpcMessageType.AcpOfferStatusSet: case RpcMessageType.AcpOfferStatusSet:
return acpOfferStatusSet(params); return acpOfferStatusSet(params);
@@ -134,11 +223,8 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
const userMsg: UserMessage = [RpcMessageType.User, user]; const userMsg: UserMessage = [RpcMessageType.User, user];
send(userMsg); send(userMsg);
await sendUserOffers(); await pushUserOffers();
await pushUserWants();
const wants = await pg.userWants(user);
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
send(wantsMsg);
if (isAdmin(user)) return adminConnection(); if (isAdmin(user)) return adminConnection();
} }
@@ -151,10 +237,12 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
)) ))
} }
async function sendUserOffers() { async function pushUserOffers() {
const userOffers = await offers.userOffers(user); send(await userOffers());
const offersMsg: OffersMessage = [RpcMessageType.Offers, userOffers]; }
send(offersMsg);
async function pushUserWants() {
send(await userWants());
} }
return { return {
@@ -162,76 +250,7 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
send, send,
onConnection, onConnection,
sendUserOffers, pushUserOffers,
pushUserWants,
} }
} }
export type Rpc = ReturnType<typeof createRpc>;
export enum RpcMessageType {
Ping = 'Ping',
Pong = 'Pong',
// points out user is anon
WhoDis = 'WhoDis',
Bundles = 'Bundles',
Offers = 'Offers',
List = 'List',
Wants = 'Wants',
User = 'User',
Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers',
AcpListingsSync = 'AcpListingsSync',
WantRm = 'WantRm',
}
type ErrorMessage = [RpcMessageType.Error, string];
type PongMessage = [RpcMessageType.Pong, []];
type PingMessage = [RpcMessageType.Ping, []];
type WhoDisMessage = [RpcMessageType.WhoDis, []];
type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
export type RpcMessage =
PingMessage |
PongMessage |
WhoDisMessage |
UserMessage |
WantsMessage |
OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage |
AcpOfferStatusSetMessage |
AcpOffersMessage |
AcpListingsSyncMessage |
ErrorMessage;
type OfferStatusSetParams = {
id: UUID,
status: Status,
};
type WantRmParams = {
id: UUID,
};
+6 -6
View File
@@ -9,7 +9,7 @@ import store, { wsState } from './store'
import Nav from './components/nav'; import Nav from './components/nav';
import Offers from './routes/offers'; import Main from './routes/main';
import Acp from './routes/acp'; import Acp from './routes/acp';
import Login from './routes/login'; import Login from './routes/login';
import Search from './routes/search'; import Search from './routes/search';
@@ -18,7 +18,7 @@ import Info from './routes/info';
if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates'); if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates');
const Main = () => { const Mcrates = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
// i have no idea why this is needed, // i have no idea why this is needed,
@@ -38,10 +38,10 @@ const Main = () => {
} }
return ( return (
<main id="app"> <div id="mcrates">
<Nav /> <Nav />
<Router onChange={routeChange}> <Router onChange={routeChange}>
<Route path="/" component={Offers} /> <Route path="/" component={Main} />
<Route path="/login" component={Login} mode="login" /> <Route path="/login" component={Login} mode="login" />
<Route path="/register" component={Login} mode="register" /> <Route path="/register" component={Login} mode="register" />
<Route path="/search" component={Search} /> <Route path="/search" component={Search} />
@@ -51,14 +51,14 @@ const Main = () => {
{/* ACP */} {/* ACP */}
<Route path="/acp" component={Acp} /> <Route path="/acp" component={Acp} />
</Router> </Router>
</main> </div>
); );
}; };
const App = () => { const App = () => {
return ( return (
<Provider store={store}> <Provider store={store}>
<Main /> <Mcrates />
</Provider> </Provider>
); );
} }
+6 -2
View File
@@ -49,8 +49,12 @@ function OfferPriceBtn(offer) {
? offer.status ? offer.status
: 'Accept'; : 'Accept';
const title = accepted
? 'Click to cancel'
: 'Click to accept this offer';
return ( return (
<button disabled={confirmed} onClick={() => setStatus()} class={classes}>${offer.total} {offer.condition}</button> <button disabled={confirmed} title={title} onClick={() => setStatus()} class={classes}>${offer.total} {offer.condition}</button>
) )
} }
@@ -63,7 +67,7 @@ export default function OfferGroup(group, i) {
const detailText = `${format} ${descriptions}`; const detailText = `${format} ${descriptions}`;
return ( return (
<div class="offer" key={i}> <div class="offer" key={i} id={group.details.id}>
<section> <section>
<h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank"><ExternalLink /></a></h3> <h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank"><ExternalLink /></a></h3>
<div class='alt-text details'> <div class='alt-text details'>
+6 -5
View File
@@ -1,12 +1,10 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { ExternalLink } from 'react-feather'; import { ExternalLink } from 'react-feather';
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { State } from './../store' import { State } from './../store'
import http from './../http';
function Want(want) { function Want(want) {
const r = want.data; const r = want.data;
@@ -14,10 +12,13 @@ function Want(want) {
const [confirm, setConfirm] = useState(false); const [confirm, setConfirm] = useState(false);
function onClick() { function onClick() {
if (confirm) return ws.sendWantRm(want.id); if (confirm) {
setConfirm(false);
return ws.sendWantRm(want.id);
}
setTimeout(() => setConfirm(false), 2500); setTimeout(() => setConfirm(false), 2500);
return setConfirm(true); return setConfirm(true);
}; }
const text = confirm const text = confirm
? 'confirm' ? 'confirm'
-13
View File
@@ -33,15 +33,10 @@ api.interceptors.response.use(onSuccess, onError);
const actions = { const actions = {
// TODO move to rpc // TODO move to rpc
postWantSearch: async function postWantSearch(query) { postWantSearch: async function postWantSearch(query) {
return api.post('/want/search', { query }); return api.post('/want/search', { query });
}, },
postWant: async function postWant(releaseId) {
return api.post('/want', { releaseId });
},
postDiscogs: async function postDiscogs(username) { postDiscogs: async function postDiscogs(username) {
return api.post('/want/discogs', { username }); return api.post('/want/discogs', { username });
}, },
@@ -71,14 +66,6 @@ const actions = {
password, password,
}); });
}, },
postOffers: async function postOffers(listingIds, shipping, baseShipping) {
return api.post('/offers', {
listingIds,
shipping,
baseShipping,
});
},
}; };
export default actions; export default actions;
-1
View File
@@ -10,7 +10,6 @@ import Want from './../components/want';
const List = () => { const List = () => {
const wants = useSelector((state: State) => state.wants); const wants = useSelector((state: State) => state.wants);
const user = useSelector((state: State) => state.user);
if (!wants) { if (!wants) {
return <div> loading... </div>; return <div> loading... </div>;
+47
View File
@@ -0,0 +1,47 @@
import { h } from 'preact';
import { route } from 'preact-router';
import { useSelector } from 'react-redux'
import { State } from './../store';
import * as toast from './../toast';
import OfferGroup from './../components/offer.group';
const GroupNav = (want) => {
const href = want._count.offers ? `#${want.release}` : null;
return (
<div class=''>
<a href={href}>{want.data.artists_sort} - {want.data.title}</a>
<span>{want._count.offers}</span>
</div>
);
};
function Home() {
const offers = useSelector((state: State) => state.offers);
const wants = useSelector((state: State) => state.wants);
if (offers && offers.length === 0) {
toast.info('no offers', 'You have no offers yet, search for some more items.');
route('/search');
}
if (!offers || !wants) return <div>loading...</div>;
return (
<main >
<nav>
<div class='header'>
<span>Record</span>
<span>Offers</span>
</div>
{wants.map(GroupNav)}
</nav>
<div class={'offers'}>
{offers.map(OfferGroup)}
</div>
</main>
);
}
export default Home;
-27
View File
@@ -1,27 +0,0 @@
import { h } from 'preact';
import { route } from 'preact-router';
import { useSelector } from 'react-redux'
import { State } from './../store';
import * as toast from './../toast';
import OfferGroup from './../components/offer.group';
function Home() {
const offers = useSelector((state: State) => state.offers);
if (offers && offers.length === 0) {
toast.info('no offers', 'You have no offers yet, search for some more items.');
route('/search');
}
if (!offers) return <div>loading...</div>;
return (
<div class={'offers'}>
{offers.map(OfferGroup)}
</div>
);
}
export default Home;
+13 -14
View File
@@ -7,25 +7,22 @@ import { State } from './../store';
import http from '../http'; import http from '../http';
function Result(r, wants) { function Result(r, wants, ws) {
const [added, setAdded] = useState(false)
async function onClick() { async function onClick() {
const [error, want] = await http.postWant(r.id); ws.sendWantAdd(r.id);
if (error) return false;
setAdded(true);
} }
const wanted = wants.some(w => w.release === r.id); // releaseIds are bigints on server
const wanted = wants.some(w => BigInt(w.release) === BigInt(r.id));
const classes = `${added || wanted ? 'added' : ''}`; const classes = `${wanted ? 'added' : ''}`;
const btnText = added const btnText = wanted
? <Check /> ? <Check />
: '+'; : '+';
return ( return (
<article key={r.id}> <article key={r.id}>
<button onClick={onClick} disabled={added} 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"><ExternalLink /></a></h3>
<h5 class='alt-text' >{r.year}</h5> <h5 class='alt-text' >{r.year}</h5>
@@ -45,6 +42,7 @@ const User = () => {
const [infoText, setInfoText] = 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);
async function fetchResults() { async function fetchResults() {
const [error, results] = await http.postWantSearch(query); const [error, results] = await http.postWantSearch(query);
@@ -68,7 +66,8 @@ const User = () => {
setInfoText(`Added ${wants.length} items to your list.`); setInfoText(`Added ${wants.length} items to your list.`);
} }
const searchRef = useRef<HTMLInputElement>() const searchRef = useRef<HTMLInputElement>();
const resultsRef = useRef<HTMLDivElement>();
useEffect(() => { useEffect(() => {
searchRef.current.focus() searchRef.current.focus()
@@ -85,7 +84,7 @@ const User = () => {
} }
const topEl = results.length const topEl = results.length
? <button onClick={() => window.scrollTo(0, 0)}> top </button> ? <button onClick={() => resultsRef.current.scrollTo(0, 0)}> top </button>
: null; : null;
const infoEl = infoText const infoEl = infoText
@@ -111,9 +110,9 @@ const User = () => {
<hr /> <hr />
<p>Data provided by Discogs.</p> <p>Data provided by Discogs.</p>
</div> </div>
<div class='results'> <div class='results' ref={resultsRef}>
{infoEl} {infoEl}
{results.map(r => Result(r, wants))} {results.map(r => Result(r, wants, ws))}
{topEl} {topEl}
</div> </div>
</div> </div>
+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;
}
+11 -379
View File
@@ -23,32 +23,6 @@ img {
height: auto; height: auto;
} }
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/raleway/v28/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvaorCIPrE.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcecodepro/v21/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQtMlrTA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: normal;
src: url(https://fonts.gstatic.com/s/notosansmono/v20/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ093dVQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
// stuff from UX laws // stuff from UX laws
html { html {
color:var(--base-font-color); color:var(--base-font-color);
@@ -71,7 +45,7 @@ html {
--medium-font-size: 1.777em; --medium-font-size: 1.777em;
--large-font-size: 2.369em; --large-font-size: 2.369em;
--xlarge-font-size: 3.157em; --xlarge-font-size: 3.157em;
--base-font-family: 'Raleway', Arial, Sans-Serif; --base-font-family: 'Barlow', Arial, Sans-Serif;
--secondary-font-family: 'Noto Sans Mono', monospace; --secondary-font-family: 'Noto Sans Mono', monospace;
--base-font-color: var(--color-off-white); --base-font-color: var(--color-off-white);
--secondary-font-color: var(--color-mid-grey); --secondary-font-color: var(--color-mid-grey);
@@ -131,6 +105,11 @@ button, input, optgroup, select, textarea {
line-height:var(--base-line-height); line-height:var(--base-line-height);
} }
button, [type=submit] {
padding: var(--spacing-half);
background: var(--background-1);
}
input:hover, textarea:hover { input:hover, textarea:hover {
color:var(--base-font-color); color:var(--base-font-color);
border:var(--hover-border) border:var(--hover-border)
@@ -156,7 +135,7 @@ button, [type=button], [type=reset], [type=submit] {
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
font-size: var(--small-font-size); font-size: var(--small-font-size);
font-weight: bold; // font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
&:hover { &:hover {
@@ -194,180 +173,6 @@ p {
margin:0 0 var(--spacing) margin:0 0 var(--spacing)
} }
// ok actual styles
body {
padding-top: calc(var(--spacing)/2);
}
main {
padding: 0 var(--spacing-double) 0 var(--spacing-double);
}
button, [type=submit] {
padding: var(--spacing-half);
background: var(--background-1);
}
.landing {
display: grid;
grid-template-columns: 1fr 2fr;
grid-gap: calc(var(--spacing-double) * 2);
grid-template-areas:
"login swirl"
"login words";
.login {
grid-area: login;
button {
width: 100%;
}
}
.swirl {
grid-area: swirl;
height: 15rem;
}
.words {
grid-area: words;
text-align: center;
}
}
.info {
display: grid;
grid-template-areas:
"words swirl"
"words contact";
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content 1fr;
section {
margin-bottom: var(--spacing);
}
.contact {
grid-area: contact;
text-align: center;
}
.words {
grid-area: words;
}
.swirl {
grid-area: swirl;
height: 15rem;
}
}
.alt-text, .alt {
font-family: var(--secondary-font-family);
// font-size: var(--small-font-size);
text-transform: uppercase;
}
article {
background-color: var(--background-1);
button {
border-color: var(--background-2);
}
}
.search {
display: grid;
grid-template-columns: 1fr 2fr;
.results {
display: grid;
padding-left: var(--spacing);
grid-gap: var(--spacing);
article {
display: grid;
padding: var(--spacing);
grid-template-columns: 1fr 4fr min-content;
}
}
button {
margin-right: var(--spacing);
background: var(--background-2);
border-color: var(--background-1);
&.added {
background: var(--color-green);
cursor: auto;
}
}
}
.list {
display: grid;
grid-template-columns:
1fr 1fr 1fr;
grid-gap: var(--spacing);
article {
display: grid;
// grid-template-rows:
// 1fr min-content min-content;
grid-template-areas:
"title img"
"details details"
"remove remove";
button {
margin-top: var(--spacing-half);
grid-area: remove
}
.details {
grid-area: details;
}
padding: var(--spacing);
img {
justify-self: end;
}
}
}
form {
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);
}
}
a { a {
color: var(--secondary-font-color); color: var(--secondary-font-color);
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
@@ -378,184 +183,11 @@ a {
} }
} }
nav { .alt-text, .alt {
a {
font-size: var(--small-font-size);
margin-right: var(--spacing);
}
}
header {
display: grid;
grid-template-columns: 1fr 1fr min-content;
padding-bottom: var(--spacing);
a {
white-space: nowrap;
}
}
.bundles {
flex-flow: row;
.bundle {
margin-bottom: var(--spacing-double);
section {
margin-bottom: var(--spacing);
}
.items {
display: grid;
grid-template-columns: repeat(5, 1fr);
div {
margin-right: var(--spacing);
margin-bottom: var(--spacing);
display: grid;
&.bold {
font-weight: bold;
background: var(--background-2);
}
}
}
}
}
.offers {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing);
.offer {
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: min-content min-content;
grid-template-areas:
"details img"
"lines lines";
padding: var(--spacing);
background: var(--background-1);
.details {
font-size: var(--small-font-size);
}
.status {
margin-top: var(--spacing-half);
}
section {
grid-area: details;
}
img {
grid-area: img;
justify-self: end;
margin-left: var(--spacing-half);
}
.lines {
grid-area: lines;
display: flex;
flex-wrap: wrap;
column-gap: var(--spacing-half);
button {
margin-top: var(--spacing-half);
border-color: var(--background-2);
&.accepted {
background: var(--color-accepted);
}
&.confirmed {
cursor: auto;
background: var(--color-confirmed);
}
&.neg {
margin-left: var(--spacing-half);
}
}
}
}
}
.number {
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
// font-size: var(--small-font-size);
text-transform: uppercase; text-transform: uppercase;
} }
.swirl { @import 'fonts.less';
background-image: url("./../assets/swirl.fix.svg"); @import 'styles.less';
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: var(--spacing);
}
@media (max-width:900px) {
.landing {
grid-template-columns: 1fr;
grid-template-areas:
"swirl"
"login"
"words";
}
.list {
grid-template-columns: 1fr;
}
.search {
grid-template-columns: 1fr;
}
.offers {
grid-template-columns: 1fr;
}
.info {
grid-template-columns: 1fr;
// is necessary for some reason
grid-template-areas:
"swirl"
"contact"
"words";
}
main {
padding: 0 var(--spacing) 0 var(--spacing);
}
}
@media (max-width:1200px) {
img {
width: 5rem;
}
}
// ------------
// 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;
}
}
+410
View File
@@ -0,0 +1,410 @@
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;
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;
grid-template-areas:
"words swirl"
"words contact";
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content 1fr;
section {
margin-bottom: var(--spacing);
}
.contact {
grid-area: contact;
text-align: center;
}
.words {
grid-area: words;
}
.swirl {
grid-area: swirl;
height: 15rem;
}
}
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;
}
}
.bundles {
flex-flow: row;
.bundle {
margin-bottom: var(--spacing-double);
section {
margin-bottom: var(--spacing);
}
.items {
display: grid;
grid-template-columns: repeat(5, 1fr);
div {
margin-right: var(--spacing);
margin-bottom: var(--spacing);
display: grid;
&.bold {
font-weight: bold;
background: var(--background-2);
}
}
}
}
}
.offers {
display: grid;
// grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing);
.offer {
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: min-content min-content;
grid-template-areas:
"details img"
"lines lines";
padding: var(--spacing);
background: var(--background-1);
.details {
font-size: var(--small-font-size);
}
.status {
margin-top: var(--spacing-half);
}
section {
grid-area: details;
}
img {
grid-area: img;
justify-self: end;
margin-left: var(--spacing-half);
}
.lines {
grid-area: lines;
display: flex;
flex-wrap: wrap;
column-gap: var(--spacing-half);
button {
margin-top: var(--spacing-half);
border-color: var(--background-2);
&.accepted {
background: var(--color-accepted);
}
&.confirmed {
cursor: auto;
background: var(--color-confirmed);
}
&.neg {
margin-left: var(--spacing-half);
}
}
}
}
}
.number {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}
.swirl {
background-image: url("./../assets/swirl.fix.svg");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: var(--spacing);
}
@media (max-width:900px) {
.landing {
grid-template-columns: 1fr;
grid-template-areas:
"swirl"
"login"
"words";
}
.list {
grid-template-columns: 1fr;
}
.search {
grid-template-columns: 1fr;
}
.offers {
grid-template-columns: 1fr;
}
.info {
grid-template-columns: 1fr;
// is necessary for some reason
grid-template-areas:
"swirl"
"contact"
"words";
}
#mcrates {
padding: 0 var(--spacing) 0 var(--spacing);
}
main {
grid-template-columns: 1fr;
grid-template-rows: 1fr 3fr;
grid-gap: var(--spacing-half);
grid-template-areas:
"nav"
"content";
}
}
@media (max-width:1200px) {
img {
width: 5rem;
}
}
// ------------
// toasts
.iziToast {
border-radius: 0;
border-color: var(--base-border-color);
.iziToast-body {
font-family: var(--base-font-family);
font-size: var(--base-font-size);
}
.iziToast-title {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}
}
+1
View File
@@ -6,6 +6,7 @@
<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">
<% preact.headEnd %> <% preact.headEnd %>
</head> </head>
<body> <body>
+1 -1
View File
@@ -3,7 +3,7 @@ import toast from 'izitoast';
export function info(title, message) { export function info(title, message) {
toast.info({ toast.info({
title, title,
message, message: message || '',
position: 'bottomCenter', position: 'bottomCenter',
color: '#1f2128', color: '#1f2128',
titleColor: '#F4F1D0', titleColor: '#F4F1D0',
+8 -1
View File
@@ -1,5 +1,6 @@
import { wantsState, wsState, userState, offersState, acpOffersState } from './store' import debounce from 'lodash/debounce';
import { wantsState, userState, offersState, acpOffersState } from './store'
import * as toast from './toast'; import * as toast from './toast';
const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`; const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`;
@@ -36,6 +37,10 @@ export default function createSocket(dispatch) {
send(['WantRm', { id }]); send(['WantRm', { id }]);
} }
function sendWantAdd(releaseId) {
send(['WantAdd', { releaseId }]);
}
function sendOffers() { function sendOffers() {
send(['Offers', {}]); send(['Offers', {}]);
} }
@@ -116,6 +121,7 @@ export default function createSocket(dispatch) {
// Connection opened // Connection opened
function onOpen() { function onOpen() {
console.log('websocket connected', SOCKET_URL) console.log('websocket connected', SOCKET_URL)
toast.info('connected', 'connected to server');
sendPing(); sendPing();
} }
@@ -155,6 +161,7 @@ export default function createSocket(dispatch) {
sendOfferStatusSet, sendOfferStatusSet,
sendWantRm, sendWantRm,
sendWantAdd,
sendOffers, sendOffers,
sendWants, sendWants,