components

This commit is contained in:
Nathan Rashleigh committed 2025-04-25 19:01:47 +10:00
1 parent d6f1b4d879
commit 4e211ecedf
17 files changed
+1555 -18

No files matched your search

+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
+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;
+49
View File
@@ -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;
+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;
+8 -1
View File
@@ -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
}
+4
View File
@@ -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,
View File
Whitespace-only changes.
+116
View File
@@ -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
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+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("/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;
}
}