components
This commit is contained in:
1 parent
d6f1b4d879
commit
4e211ecedf
17 files changed
+1555
-18
No files matched your search
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { h } from 'preact';
|
||||
import { route } from 'preact-router';
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { State } from './../store';
|
||||
import * as toast from './../toast';
|
||||
|
||||
import OfferGroup from './../components/offer.group';
|
||||
|
||||
const GroupNav = (want) => {
|
||||
if (!want._count.offers) return null
|
||||
const href = `#${want.release}`;
|
||||
|
||||
return (
|
||||
<div class=''>
|
||||
<a href={href}>{want.data.artists_sort} - {want.data.title}</a>
|
||||
<span>{want._count.offers}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function Home() {
|
||||
const offers = useSelector((state: State) => state.offers);
|
||||
const wants = useSelector((state: State) => state.wants);
|
||||
|
||||
if (offers && offers.length === 0) {
|
||||
toast.info('no offers', 'You have no offers yet, search for some more items.');
|
||||
route('/search');
|
||||
}
|
||||
|
||||
if (!offers || !wants) return <div>loading...</div>;
|
||||
|
||||
return (
|
||||
<main >
|
||||
<nav>
|
||||
<div class='header'>
|
||||
<span>Record</span>
|
||||
<span>Offers</span>
|
||||
</div>
|
||||
{wants.map(GroupNav)}
|
||||
</nav>
|
||||
<div class={'offers'}>
|
||||
{offers.map(OfferGroup)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -2,9 +2,16 @@ import { defineConfig } from "$fresh/server.ts";
|
||||
import twind from "$fresh/plugins/twindv1.ts";
|
||||
import twindConfig from "./twind.config.ts";
|
||||
import { authMiddleware } from "api/auth.middleware.ts";
|
||||
import lessPlugin from "./less.plugin.ts";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [twind(twindConfig)],
|
||||
plugins: [
|
||||
// twind(twindConfig),
|
||||
lessPlugin({
|
||||
path: "static/style",
|
||||
outDir: "static/css"
|
||||
})
|
||||
],
|
||||
middleware: {
|
||||
auth: authMiddleware
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 $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'
|
||||
|
||||
@@ -21,6 +23,8 @@ const manifest = {
|
||||
'./routes/api/auth/register.ts': $api_auth_register,
|
||||
'./routes/api/auth/status.ts': $api_auth_status,
|
||||
'./routes/index.tsx': $index,
|
||||
'./routes/login.tsx': $login,
|
||||
'./routes/register.tsx': $register,
|
||||
},
|
||||
islands: {},
|
||||
baseUrl: import.meta.url,
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,116 @@
|
||||
import { Plugin } from "$fresh/server.ts";
|
||||
import { ensureDir } from "$std/fs/ensure_dir.ts";
|
||||
import { join } from "$std/path/join.ts";
|
||||
import { parse } from "$std/path/parse.ts";
|
||||
import { dirname } from "$std/path/dirname.ts";
|
||||
|
||||
// External Less compiler for Deno
|
||||
const LESS_CDN = "https://esm.sh/less@4.2.0";
|
||||
|
||||
export interface LessPluginOptions {
|
||||
path: string;
|
||||
outDir: string;
|
||||
}
|
||||
|
||||
export default function lessPlugin(options: LessPluginOptions): Plugin {
|
||||
let watcher: Deno.FsWatcher | null = null;
|
||||
const combinedCssPath = join(options.outDir, "styles.combined.css");
|
||||
let less: any;
|
||||
|
||||
// Compile a Less file and return its CSS
|
||||
async function compileLessFile(path: string): Promise<string> {
|
||||
try {
|
||||
const lessContent = await Deno.readTextFile(path);
|
||||
const { css } = await less.render(lessContent);
|
||||
return css;
|
||||
} catch (err) {
|
||||
console.error(`Error compiling ${path}:`, err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all compiled CSS into a single file
|
||||
async function combineAndWriteCss(paths) {
|
||||
try {
|
||||
const compiledCssArray: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const css = await compileLessFile(path);
|
||||
compiledCssArray.push(css);
|
||||
}
|
||||
|
||||
const combinedCss = compiledCssArray.join("\n");
|
||||
await Deno.writeTextFile(combinedCssPath, combinedCss);
|
||||
console.log(`Combined CSS written to ${combinedCssPath}`);
|
||||
} catch (err) {
|
||||
console.error("Error combining CSS:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Start watching the style directory
|
||||
async function startWatcher() {
|
||||
try {
|
||||
// Determine directory to watch (assuming first path is representative)
|
||||
const watchDir = dirname(options.path);
|
||||
|
||||
console.log(`Starting watcher on ${watchDir}`);
|
||||
watcher = Deno.watchFs(watchDir);
|
||||
|
||||
(async () => {
|
||||
for await (const event of watcher!) {
|
||||
if (event.kind === "modify" || event.kind === "create") {
|
||||
const lessFiles = event.paths.filter(p => p.endsWith(".less"));
|
||||
if (lessFiles.length > 0) {
|
||||
console.log(`Less files changed: ${lessFiles.join(", ")}`);
|
||||
await combineAndWriteCss(lessFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
console.error("Error starting watcher:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "less",
|
||||
async buildStart() {
|
||||
try {
|
||||
// Import the Less compiler
|
||||
const lessModule = await import(LESS_CDN);
|
||||
less = lessModule.default;
|
||||
|
||||
// Ensure the output directory exists
|
||||
await ensureDir(options.outDir);
|
||||
|
||||
// Initial compilation
|
||||
await combineAndWriteCss();
|
||||
|
||||
// Start the file watcher
|
||||
await startWatcher();
|
||||
} catch (err) {
|
||||
console.error("Error initializing Less plugin:", err);
|
||||
}
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Generate a link to the combined CSS file
|
||||
const href = "/css/styles.combined.css"
|
||||
ctx.render();
|
||||
return {
|
||||
links: [{ rel: "stylesheet", href }],
|
||||
};
|
||||
},
|
||||
|
||||
async buildEnd() {
|
||||
// Clean up watcher when build ends
|
||||
if (watcher) {
|
||||
try {
|
||||
watcher.close();
|
||||
} catch (err) {
|
||||
console.error("Error closing watcher:", err);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+23
-17
@@ -1,23 +1,29 @@
|
||||
import { useSignal } from "@preact/signals";
|
||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||
import { getCookies } from "$std/http/cookie.ts";
|
||||
import { sessions } from "../api/auth.middleware.ts";
|
||||
|
||||
export const handler: Handlers = {
|
||||
async GET(req, ctx) {
|
||||
const cookies = getCookies(req.headers);
|
||||
const sessionId = cookies.session;
|
||||
|
||||
// Check if session exists and is valid
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
return new Response("", {
|
||||
status: 307,
|
||||
headers: { Location: "/login" },
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.render();
|
||||
},
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const count = useSignal(3);
|
||||
return (
|
||||
<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="/logo.svg"
|
||||
width="128"
|
||||
height="128"
|
||||
alt="the crates logo: a sliced lemon dripping with juice"
|
||||
/>
|
||||
<h1 class="text-4xl font-bold">Welcome to crates</h1>
|
||||
<p class="my-4">
|
||||
Try updating this message in the
|
||||
<code class="mx-2">./routes/index.tsx</code> file, and recrates.
|
||||
</p>
|
||||
</div>
|
||||
<div id="mcrates">
|
||||
<h1>Welcome to crates</h1>
|
||||
<p>You are logged in!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { h } from "preact";
|
||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||
import { getCookies, setCookie } from "$std/http/cookie.ts";
|
||||
import Swirl from "../components/swirl.tsx";
|
||||
import { sessions } from "../api/auth.middleware.ts";
|
||||
import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
|
||||
import { createPg } from "../api/pg.ts";
|
||||
|
||||
export const handler: Handlers = {
|
||||
async GET(req, ctx) {
|
||||
const cookies = getCookies(req.headers);
|
||||
const sessionId = cookies.session;
|
||||
|
||||
// If user is already logged in, redirect to home
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
return new Response("", {
|
||||
status: 307,
|
||||
headers: { Location: "/" },
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.render();
|
||||
},
|
||||
|
||||
async POST(req, ctx) {
|
||||
const formData = await req.formData();
|
||||
const email = formData.get("email")?.toString();
|
||||
const password = formData.get("password")?.toString();
|
||||
|
||||
if (!email || !password) {
|
||||
return ctx.render({ error: "Email and password required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Create pg instance for database operations
|
||||
const pg = createPg(console);
|
||||
|
||||
// Find user by email
|
||||
const user = await pg.userFindEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return ctx.render({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!match) {
|
||||
return ctx.render({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionId = crypto.randomUUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7); // Session expires in 7 days
|
||||
|
||||
sessions.set(sessionId, {
|
||||
userId: user.id,
|
||||
expiresAt
|
||||
});
|
||||
|
||||
// Set cookie and redirect
|
||||
const headers = new Headers({ Location: "/" });
|
||||
setCookie(headers, {
|
||||
name: "session",
|
||||
value: sessionId,
|
||||
expires: expiresAt,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax"
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return ctx.render({ error: "An error occurred during login" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function Login({ error }: { error?: string }) {
|
||||
const errorEl = error
|
||||
? <div class="alt-text">{error}</div>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div class="login">
|
||||
{errorEl}
|
||||
<form method="POST" action="/login">
|
||||
<div class="item">
|
||||
<label for="email">email</label>
|
||||
<input type="email" name="email" id="email" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="password">password</label>
|
||||
<input type="password" name="password" id="password" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<input type="submit" value="login" />
|
||||
</div>
|
||||
</form>
|
||||
<a href="/register" class="button">create account</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage(props: PageProps) {
|
||||
const { error } = props.data || {};
|
||||
|
||||
return (
|
||||
<div class="landing">
|
||||
<Login error={error} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { h } from "preact";
|
||||
import { Handlers, PageProps } from "$fresh/server.ts";
|
||||
import { getCookies } from "$std/http/cookie.ts";
|
||||
import { useState } from "preact/hooks";
|
||||
import Swirl from "../components/swirl.tsx";
|
||||
import { sessions } from "../api/auth.middleware.ts";
|
||||
|
||||
export const handler: Handlers = {
|
||||
async GET(req, ctx) {
|
||||
const cookies = getCookies(req.headers);
|
||||
const sessionId = cookies.session;
|
||||
|
||||
// If user is already logged in, redirect to home
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
return new Response("", {
|
||||
status: 307,
|
||||
headers: { Location: "/" },
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.render();
|
||||
},
|
||||
};
|
||||
|
||||
function Register() {
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const email = e.target.email.value;
|
||||
const password = e.target.password.value;
|
||||
const confirm = e.target.confirm.value;
|
||||
|
||||
if (password !== confirm) {
|
||||
setError("passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Registration failed");
|
||||
}
|
||||
|
||||
// Redirect on successful registration
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const errorEl = error
|
||||
? <div class="alt-text">{error}</div>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div class="login">
|
||||
{errorEl}
|
||||
<form action="" onSubmit={handleSubmit}>
|
||||
<div class="item">
|
||||
<label for="email">email</label>
|
||||
<input type="email" name="email" id="email" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="password">password</label>
|
||||
<input type="password" name="password" id="password" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="confirm">confirm password</label>
|
||||
<input type="password" name="confirm" id="confirm" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<input type="submit" value="register" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div class="landing">
|
||||
<Register />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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("/logo.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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user