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 twind from "$fresh/plugins/twindv1.ts";
|
||||||
import twindConfig from "./twind.config.ts";
|
import twindConfig from "./twind.config.ts";
|
||||||
import { authMiddleware } from "api/auth.middleware.ts";
|
import { authMiddleware } from "api/auth.middleware.ts";
|
||||||
|
import lessPlugin from "./less.plugin.ts";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [twind(twindConfig)],
|
plugins: [
|
||||||
|
// twind(twindConfig),
|
||||||
|
lessPlugin({
|
||||||
|
path: "static/style",
|
||||||
|
outDir: "static/css"
|
||||||
|
})
|
||||||
|
],
|
||||||
middleware: {
|
middleware: {
|
||||||
auth: authMiddleware
|
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_register from './routes/api/auth/register.ts'
|
||||||
import * as $api_auth_status from './routes/api/auth/status.ts'
|
import * as $api_auth_status from './routes/api/auth/status.ts'
|
||||||
import * as $index from './routes/index.tsx'
|
import * as $index from './routes/index.tsx'
|
||||||
|
import * as $login from './routes/login.tsx'
|
||||||
|
import * as $register from './routes/register.tsx'
|
||||||
|
|
||||||
import type { Manifest } from '$fresh/server.ts'
|
import type { Manifest } from '$fresh/server.ts'
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ const manifest = {
|
|||||||
'./routes/api/auth/register.ts': $api_auth_register,
|
'./routes/api/auth/register.ts': $api_auth_register,
|
||||||
'./routes/api/auth/status.ts': $api_auth_status,
|
'./routes/api/auth/status.ts': $api_auth_status,
|
||||||
'./routes/index.tsx': $index,
|
'./routes/index.tsx': $index,
|
||||||
|
'./routes/login.tsx': $login,
|
||||||
|
'./routes/register.tsx': $register,
|
||||||
},
|
},
|
||||||
islands: {},
|
islands: {},
|
||||||
baseUrl: import.meta.url,
|
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() {
|
export default function Home() {
|
||||||
const count = useSignal(3);
|
|
||||||
return (
|
return (
|
||||||
<div class="px-4 py-8 mx-auto bg-[#86efac]">
|
<div id="mcrates">
|
||||||
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
|
<h1>Welcome to crates</h1>
|
||||||
<img
|
<p>You are logged in!</p>
|
||||||
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>
|
</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