diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e540775 --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/crates/components/acp.tsx b/crates/components/acp.tsx new file mode 100644 index 0000000..4431904 --- /dev/null +++ b/crates/components/acp.tsx @@ -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]) => ( + + )); + + function onChange(e) { + ws.sendAcpOfferStatusSet(item.id, e.target.value); + } + + const bold = POSITIVE_STATUS.includes(item.status) + ? 'bold' + : ''; + + return ( +
+ +

{item.listing.data.release.description}

+
+
{item.user.email}
+
{item.total} - {item.condition}
+
{item.status}
+ + +
+ ); +} + +function SellerGroup(group) { + const ready = group.offers.filter(item => POSITIVE_STATUS.includes(item.status)).length; + + return ( +
+
+

{group.username} - {group.seller}

+

{ready} / {group.offers.length} offers

+

Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}

+
+ +
+ {group.offers.map(i => )} +
+
+ ) +} + +function Home() { + const offers = useSelector((state: State) => state.acpOffers); + const ws = useSelector((state: State) => state.ws); + + function sync() { + ws.sendAcpListingsSync(); + } + + return ( +
+ + +
+
+ {offers.map(SellerGroup)} +
+
+ ); +} + +export default Home; diff --git a/crates/components/info.tsx b/crates/components/info.tsx new file mode 100644 index 0000000..e90d4ad --- /dev/null +++ b/crates/components/info.tsx @@ -0,0 +1,56 @@ +import { h } from 'preact'; + +import Swirl from './../components/swirl'; + +const Info = () => { + return ( +
+ +
+
+

Info

+

Thanks for signing up!

+

Mates Crates is a record importing service in alpha.

+

Its purpose is to reduce the cost of shipping vinyl records to Melbourne, Australia.

+
+ +
+

How It Works

+

First, add some records to your List on the Search Page.

+

Mates Crates will then make Offers to you for releases you add to your List.

+

(this can take a few minutes time!)

+

Select all Offers you are interested in, the more you accept the more likely the item will be Confirmed.

+

Check back regularly for updated Offers, the global record marketplace is a living thing.

+
+ +
+

Offers

+

Not all items on your List 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!

+

Think of offers like an expression of interest, they are not confirmed until they are set to Confirmed status.

+

After that they will be shipped to Melbourne and ready for pick up.

+
+ +
+

Pick Up

+

Use the contact options to get in touch.

+

Payment is cash on delivery.

+
+ +
+

FAQ

+

Delivery Times

+

Records are usually available for pickup within a week. We order in bulk, which often means express, insured shipping to Melbourne.

+

Friends

+

Please invite other collectors to the site. More users means offers can become cheaper and shipped sooner.

+
+
+
+

Contact

+

Email

+

Discord

+
+
+ ); +}; + +export default Info; diff --git a/crates/components/list.tsx b/crates/components/list.tsx new file mode 100644 index 0000000..4122079 --- /dev/null +++ b/crates/components/list.tsx @@ -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
loading...
; + } + + if (!wants.length) { + toast.info('List Empty', 'You have no items in your list, add some with search.'); + route('/search'); + } + + return ( +
+ {wants.map(Want)} +
+ ); +}; + +export default List; diff --git a/crates/components/login.tsx b/crates/components/login.tsx new file mode 100644 index 0000000..0e9fa74 --- /dev/null +++ b/crates/components/login.tsx @@ -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() + useEffect(() => { + emailRef.current.focus() + }, []) + + + const errorEl = error + ?
{error}
+ : null; + + if (mode === 'register') return ( +
+ {errorEl} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + ); + + return ( +
+ {errorEl} +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ ); +}; + +function LoginPage({ mode }) { + return ( +
+ + +
+

record importing service

+

we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl

+

then we get together and play them

+
+
+ ) +} + +export default LoginPage; diff --git a/crates/components/main.tsx b/crates/components/main.tsx new file mode 100644 index 0000000..0e406c7 --- /dev/null +++ b/crates/components/main.tsx @@ -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 ( +
+ {want.data.artists_sort} - {want.data.title} + {want._count.offers} +
+ ); +}; + +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
loading...
; + + return ( +
+ +
+ {offers.map(OfferGroup)} +
+
+ ); +} + +export default Home; diff --git a/crates/components/search.tsx b/crates/components/search.tsx new file mode 100644 index 0000000..47d6e82 --- /dev/null +++ b/crates/components/search.tsx @@ -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 + ? + : '+'; + + return ( +
+ +
+

{r.title}

+
{r.year}
+
{r.country}
+
{r.format.join(' ')}
+
+ +
+ ); +} + +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(); + const resultsRef = useRef(); + + useEffect(() => { + searchRef.current.focus() + }, []) + + function handleSubmit(e) { + e.preventDefault(); + fetchResults(); + } + + function handleDiscogsSubmit(e) { + e.preventDefault(); + submitWantlist(); + } + + const topEl = results.length + ? + : null; + + return ( + + ); +}; + +export default User; diff --git a/crates/fresh.config.ts b/crates/fresh.config.ts index f3eacf9..7116d99 100644 --- a/crates/fresh.config.ts +++ b/crates/fresh.config.ts @@ -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 } diff --git a/crates/fresh.gen.ts b/crates/fresh.gen.ts index bb6e6ec..608b80f 100644 --- a/crates/fresh.gen.ts +++ b/crates/fresh.gen.ts @@ -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, diff --git a/crates/import_map.json b/crates/import_map.json deleted file mode 100644 index e69de29..0000000 diff --git a/crates/less.plugin.ts b/crates/less.plugin.ts new file mode 100644 index 0000000..9ceb536 --- /dev/null +++ b/crates/less.plugin.ts @@ -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 { + 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); + } + } + }, + }; +} \ No newline at end of file diff --git a/crates/routes/index.tsx b/crates/routes/index.tsx index ec74e51..ca63689 100644 --- a/crates/routes/index.tsx +++ b/crates/routes/index.tsx @@ -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 ( -
-
- the crates logo: a sliced lemon dripping with juice -

Welcome to crates

-

- Try updating this message in the - ./routes/index.tsx file, and recrates. -

-
+
+

Welcome to crates

+

You are logged in!

); } diff --git a/crates/routes/login.tsx b/crates/routes/login.tsx new file mode 100644 index 0000000..6c65bb8 --- /dev/null +++ b/crates/routes/login.tsx @@ -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 + ?
{error}
+ : null; + + return ( + + ); +} + +export default function LoginPage(props: PageProps) { + const { error } = props.data || {}; + + return ( +
+ + +
+

record importing service

+

we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl

+

then we get together and play them

+
+
+ ); +} \ No newline at end of file diff --git a/crates/routes/register.tsx b/crates/routes/register.tsx new file mode 100644 index 0000000..9620cca --- /dev/null +++ b/crates/routes/register.tsx @@ -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 + ?
{error}
+ : null; + + return ( + + ); +} + +export default function RegisterPage() { + return ( +
+ + +
+

record importing service

+

we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl

+

then we get together and play them

+
+
+ ); +} \ No newline at end of file diff --git a/crates/static/style/fonts.less b/crates/static/style/fonts.less new file mode 100644 index 0000000..32c2f2e --- /dev/null +++ b/crates/static/style/fonts.less @@ -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; +} \ No newline at end of file diff --git a/crates/static/style/index.less b/crates/static/style/index.less new file mode 100644 index 0000000..3b16d61 --- /dev/null +++ b/crates/static/style/index.less @@ -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'; diff --git a/crates/static/style/styles.less b/crates/static/style/styles.less new file mode 100644 index 0000000..5721297 --- /dev/null +++ b/crates/static/style/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; + } +} \ No newline at end of file