This commit is contained in:
metacryst
2025-12-20 15:26:48 -06:00
commit c92742e8a1
93 changed files with 6146 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
package-lock.json
node_modules
.env
content
db/db.json
qrCodes/qr_codes

View File

@@ -0,0 +1,27 @@
import OrderedObject from "../server/db/model/OrderedObject.js"
window.testSuites.push(
class testOrderedObject {
async addShouldFailIfKeyIsDuplicate() {
class Test extends OrderedObject {
}
let test = new Test()
test.add("1", {name: "hello"})
try {
test.add("1", {name: "bye"})
} catch(e){
return
}
return "Received no error!"
}
}
)

57
_test/test.js Normal file
View File

@@ -0,0 +1,57 @@
let scriptToPaste = `
<script type="module" src="./_test/test.js"></script>
`;
console.log("Tests initializing.")
window.testSuites = [];
/* Server - DB */
import ("./OrderedObject.test.js")
window.test = async function() {
// window.testSuites.sort();
window.alert = () => true;
window.confirm = () => true;
console.clear();
let failed = 0;
let success = 0;
var start = new Date();
for(let j=0; j<window.testSuites.length; j++) {
let testSuite = window.testSuites[j];
console.log(`%c ➽ ${j+1} ${testSuite.name.replace("test", "")}`, 'color: #ffffff; font-size: 17px; padding-left: -20px; padding-top: 10px; padding-bottom: 10px; text-align: right;')
let suite = new testSuite();
let testNum = 0;
let suiteContents = Object.getOwnPropertyNames(testSuite.prototype)
for(let i=0; i<suiteContents.length; i++) {
let test = suiteContents[i];
if(typeof suite[test] === 'function' && test !== "constructor") {
testNum++;
let fail = await suite[test]();
if(fail) {
failed++;
console.log(`%c ${testNum}. ${test}: ${fail}`, 'background: #222; color: rgb(254, 62, 43)');
} else {
success++;
console.log(`%c ${testNum}. ${test}`, 'background: #222; color: #00FF00');
}
}
}
}
console.log("")
console.log("")
let elapsed = new Date() - start;
if(failed === 0) {
console.log(`%cRan ${failed+success} tests in ${elapsed}ms`, 'background: #222; color: #00FF00');
} else {
console.log(`%cRan ${failed+success} tests in ${elapsed}ms`, 'background: #222; color: rgb(254, 62, 43)');
}
console.log(`%c ${success} passed`, 'background: #222; color: #00FF00');
console.log(`%c ${failed} failed`, 'background: #222; color: rgb(254, 62, 43)');
}
window.wait = ms => new Promise(res => setTimeout(res, ms));
window.__defineGetter__("test", test);

2
main.js Normal file
View File

@@ -0,0 +1,2 @@
import Server from "./server/index.js"
let server = new Server()

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "Hyperia",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "node main.js"
},
"dependencies": {
"argon2": "^0.44.0",
"chalk": "^4.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-useragent": "^2.0.2",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"stripe": "^20.0.0",
"ws": "^8.18.3",
"zod": "^4.1.12"
}
}

87
server/_/quilldb.js Normal file
View File

@@ -0,0 +1,87 @@
import chalk from 'chalk'
import path from 'path'
import fs from 'fs/promises'
import { pathToFileURL } from 'url'
function Node(node) {
let traits = [
"labels"
]
for(let i = 0; i < traits.length; i++) {
if(!node[traits[i]]) {
throw new Error(`Node is missing field "${traits[i]}": ${JSON.stringify(node)}`)
}
}
}
export default class QuillDB {
#nodes; get nodes() {return this.#nodes};
#edges; get edges() {return this.#edges};
#labels = {}; get labels() {return this.#labels}
constructor() {
this.loadData()
}
async loadData() {
const dbData = await fs.readFile(path.join(process.cwd(), 'db/db.json'), 'utf8');
let dbJson;
try {
dbJson = JSON.parse(dbData);
} catch {
dbJson = []
}
this.#nodes = dbJson["nodes"];
this.#edges = dbJson["edges"];
let labelModels = await this.getLabelModels();
// Index by label
for (const [id, entry] of Object.entries(this.#nodes)) {
Node(entry)
this.#checkLabelSchemas(id, entry, labelModels)
}
for (const [id, entry] of Object.entries(this.#edges)) {
Edge(entry)
this.#checkLabelSchemas(id, entry, labelModels)
}
console.log(chalk.yellow("DB established."))
Object.preventExtensions(this);
}
async getLabelModels() {
const labelHandlers = {};
const labelDir = path.join(process.cwd(), 'server/db/model');
const files = await fs.readdir(labelDir);
for (const file of files) {
if (!file.endsWith('.js')) continue;
const label = path.basename(file, '.js');
const modulePath = path.join(labelDir, file);
const module = await import(pathToFileURL(modulePath).href);
labelHandlers[label] = module.default;
if (!this.#labels[label]) {
this.#labels[label] = [];
}
}
return labelHandlers
}
#checkLabelSchemas(id, entry, labelModels) {
entry.labels.forEach(label => {
const model = labelModels[label];
if (!model) {
throw new Error("Data has unknown label or missing model: " + label)
}
model(entry);
this.#labels[label].push(id);
});
}
async getAll() {
return { nodes: this.#nodes }
}
}

89
server/auth.js Normal file
View File

@@ -0,0 +1,89 @@
import dotenv from "dotenv"
import jwt from 'jsonwebtoken'
import argon2 from 'argon2'
dotenv.config();
export default class AuthHandler {
ips = new Map()
#secret
constructor() {
this.#secret = process.env.JWT_SECRET;
}
isLoggedInUser(req, res) {
const token = req.cookies.auth_token;
if (!token) {
return false;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
return true;
} catch (err) {
return false;
}
}
getProfile(req, res) {
const token = req.cookies.auth_token;
if (!token) return res.status(401).send({ error: "No auth token" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
const email = payload.email;
const user = db.members.getByEmail(email);
res.send({ email: user.email, name: user.firstName + " " + user.lastName });
} catch (err) {
res.status(401).send({ error: "Invalid token" });
}
}
async login(req, res) {
const { email, password } = req.body;
let foundUser = global.db.members.getByEmail(email)
if(!foundUser) {
res.status(400).json({ error: 'Incorrect email.' });
return;
}
const storedHash = foundUser.password
const valid = await argon2.verify(storedHash, password);
if (!valid) {
res.status(400).json({ error: 'Incorrect password.' });
} else {
const payload = { email: foundUser.email };
console.log(payload)
const secret = process.env.JWT_SECRET;
const options = { expiresIn: "2h" };
const token = jwt.sign(payload, secret, options);
res.cookie("auth_token", token, {
httpOnly: true, // cannot be accessed by JS
secure: process.env.ENV === "production", // only over HTTPS
sameSite: "lax", // like SameSiteLaxMode
maxAge: 2 * 60 * 60 * 1000, // 2 hours in milliseconds
path: "/", // available on entire site
domain: process.env.ENV === "production" ? "." + process.env.BASE_URL : undefined
});
res.redirect("/")
}
}
logout(req, res) {
res.cookie('auth_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0, // expire immediately
path: '/',
domain: process.env.ENV === "production" ? "." + process.env.BASE_URL : undefined
});
res.redirect("/")
}
}

32
server/db/db.js Normal file
View File

@@ -0,0 +1,32 @@
import fs from 'fs/promises'
import chalk from 'chalk'
import path from 'path'
export default class Database {
logs
constructor() {
this.loadData()
}
async loadData() {
const dbData = await fs.readFile(path.join(process.cwd(), 'db/db.json'), 'utf8');
let dbJson;
try {
dbJson = JSON.parse(dbData);
} catch {
dbJson = []
}
this.logs = dbJson
setInterval(() => {
console.log("saving db")
global.db.saveData()
}, 5000)
}
async saveData() {
let string = JSON.stringify(this.logs, null, 4)
await fs.writeFile(path.join(process.cwd(), 'db/db.json'), string, "utf8");
}
}

167
server/index.js Normal file
View File

@@ -0,0 +1,167 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import http from 'http'
import fs from 'fs'
import chalk from 'chalk'
import moment from 'moment'
import path from 'path'
import * as useragent from "express-useragent";
import "./util.js"
import Socket from './ws/ws.js'
import Database from "./db/db.js"
import AuthHandler from './auth.js';
export default class Server {
db;
auth;
UIPath = path.join(global.__dirname, '../ui')
DBPath = path.join(global.__dirname, './db')
registerRoutes(router) {
/* Auth */
router.post('/login', this.auth.login)
router.get('/profile', this.auth.getProfile)
router.get('/signout', this.auth.logout)
/* Site */
router.get('/*', this.get)
return router
}
verifyToken = (req, res, next) => {
const { token } = req.query;
if (!token) {
return res.status(400).json({ error: 'Token is required' });
}
let fromDB = this.db.tokens.get(token)
if (!fromDB) {
return res.status(403).json({ error: 'Invalid or expired token' });
} else if(fromDB.used) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
next()
}
authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Authorization token required.' });
}
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Malformed authorization header.' })
}
try {
const payload = this.auth.verify(token);
req.user = payload;
return next();
} catch (err) {
return res.status(403).json({ error: 'Invalid or expired token.' });
}
}
get = async (req, res) => {
let url = req.url
let publicSite = () => {
let filePath;
if(url.startsWith("/_")) {
filePath = path.join(this.UIPath, url);
} else if(url.includes("75820185")) {
filePath = path.join(this.UIPath, "public", url.split("75820185")[1]);
} else {
filePath = path.join(this.UIPath, "public", "index.html");
}
res.sendFile(filePath);
}
let privateSite = () => {
let filePath;
let platformFolder = req.useragent.isMobile ? "mobile" : "desktop"
if(url.startsWith("/_")) {
filePath = path.join(this.UIPath, url);
} else if(url.includes("75820185")) {
filePath = path.join(this.UIPath, platformFolder, url.split("75820185")[1]);
} else {
filePath = path.join(this.UIPath, platformFolder, "index.html");
}
res.sendFile(filePath);
}
if(!this.auth.isLoggedInUser(req, res)) {
publicSite()
} else {
privateSite()
}
}
logRequest(req, res, next) {
const formattedDate = moment().format('M.D');
const formattedTime = moment().format('h:mma');
if(req.url.includes("/api/")) {
console.log(chalk.blue(` ${req.method} ${req.url} | ${formattedDate} ${formattedTime}`));
} else {
if(req.url === "/")
console.log(chalk.gray(` ${req.method} ${req.url} | ${formattedDate} ${formattedTime}`));
}
next();
}
logResponse(req, res, next) {
const originalSend = res.send;
res.send = function (body) {
if(res.statusCode >= 400) {
console.log(chalk.blue( `<-${chalk.red(res.statusCode)}- ${req.method} ${req.url} | ${chalk.red(body)}`));
} else {
console.log(chalk.blue(`<-${res.statusCode}- ${req.method} ${req.url}`));
}
originalSend.call(this, body);
};
next();
}
constructor() {
this.db = new Database()
global.db = this.db
this.auth = new AuthHandler()
const app = express();
app.use(cors({ origin: '*' }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(useragent.express());
app.use(this.logRequest);
app.use(this.logResponse);
let router = express.Router();
this.registerRoutes(router)
app.use('/', router);
const server = http.createServer(app);
global.Socket = new Socket(server);
const PORT = 4001;
server.listen(PORT, () => {
console.log("\n")
console.log(chalk.yellow("*************** Hyperia ***************"))
console.log(chalk.yellowBright(`Server is running on port ${PORT}: http://localhost`));
console.log(chalk.yellow("***************************************"))
console.log("\n")
});
process.on('SIGINT', async () => {
console.log(chalk.red('Closing server...'));
console.log(chalk.green('Database connection closed.'));
process.exit(0);
});
Object.preventExtensions(this);
}
}

30
server/util.js Normal file
View File

@@ -0,0 +1,30 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
global.__dirname = dirname(__filename);
global.ServerError = class extends Error {
constructor(status, msg) {
super(msg);
this.status = status;
}
}
global.currentTime = function () {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const year = now.getFullYear();
let hours = now.getHours();
const ampm = hours >= 12 ? "pm" : "am";
hours = hours % 12 || 12; // convert to 12-hour format
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
const ms = String(now.getMilliseconds()).padStart(4, "0"); // 4-digit like "5838"
return `${month}.${day}.${year}-${hours}:${minutes}:${seconds}${ms}${ampm}`;
}

View File

@@ -0,0 +1,43 @@
import { z } from "zod"
const sendSchema = z.object({
forum: z.string(),
text: z.string(),
})
.strict()
const getSchema = z.object({
forum: z.string(),
number: z.number()
})
.strict()
export default class ForumHandler {
static handleSend(msg, ws) {
try {
global.db.posts.add(msg.text, msg.forum, ws.userEmail)
global.Socket.broadcast({event: "new-post", app: "FORUM", forum: msg.forum, msg: this.handleGet({forum: msg.forum, number: 100})})
return {success: true}
} catch(e) {
console.error(e)
}
}
static handleGet(msg) {
let data = global.db.posts.get(msg.forum, msg.number)
return data
}
static handle(operation, msg, ws) {
switch(operation) {
case "SEND":
if(!sendSchema.safeParse(msg).success) throw new Error("Incorrectly formatted Forum ws message!")
return this.handleSend(msg, ws)
case "GET":
if(!getSchema.safeParse(msg).success) throw new Error("Incorrectly formatted Forum ws message!")
return this.handleGet(msg)
}
}
}

View File

@@ -0,0 +1,40 @@
import { z } from "zod"
const sendSchema = z.object({
conversation: z.string(),
text: z.string(),
})
.strict()
export default class MessagesHandler {
static handleSend(msg, ws) {
let user = global.db.members.getByEmail(ws.userEmail)
let convo = global.db.conversations.get(msg.conversation)
if(convo.between.includes(`MEMBER-${user.id}`)) {
global.db.messages.add(msg.conversation, msg.text, `MEMBER-${user.id}`)
global.Socket.broadcast({event: "new-message", app: "MESSAGES", msg: {conversationID: convo.id, messages: global.db.messages.getByConversation(`CONVERSATION-${msg.conversation}`)}})
} else {
throw new Error("Can't add to a conversation user is not a part of!")
}
return {success: true}
}
static handleGet(ws) {
let user = global.db.members.getByEmail(ws.userEmail)
let data = global.db.conversations.getByMember(`MEMBER-${user.id}`)
return data
}
static handle(operation, msg, ws) {
switch(operation) {
case "GET":
return this.handleGet(ws)
case "SEND":
if(!sendSchema.safeParse(msg).success) throw new Error("Incorrectly formatted Forum ws message!")
return this.handleSend(msg, ws)
}
}
}

79
server/ws/ws.js Normal file
View File

@@ -0,0 +1,79 @@
import { WebSocket, WebSocketServer } from 'ws'
import { z } from "zod"
import jwt from 'jsonwebtoken'
import ForumHandler from "./handlers/ForumHandler.js"
import MessagesHandler from "./handlers/MessagesHandler.js"
export default class Socket {
wss;
messageSchema = z.object({
id: z.string(),
app: z.string(),
operation: z.string().optional(),
msg: z.union([
z.object({}).passthrough(), // allows any object
z.array(z.any()) // allows any array
]).optional()
})
.superRefine((data, ctx) => {
if (data.operation !== "GET" && data.msg === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["msg"],
message: "msg is required when operation is not GET"
})
}
})
.strict()
constructor(server) {
this.wss = new WebSocketServer({ server });
this.wss.on('connection', (ws, req) => {
console.log('✅ New WebSocket client connected');
function parseCookies(cookieHeader = "") {
return Object.fromEntries(
cookieHeader.split(";").map(c => {
const [key, ...v] = c.trim().split("=");
return [key, decodeURIComponent(v.join("="))];
})
);
}
const cookies = parseCookies(req.headers.cookie);
const token = cookies.auth_token;
if (!token) throw new Error("No auth token");
const payload = jwt.verify(token, process.env.JWT_SECRET);
ws.userEmail = payload.email;
ws.on('message', (msg) => {
this.handleMessage(msg, ws);
});
ws.on('close', () => {
console.log('Client disconnected');
});
});
console.log('WebSocket server initialized');
}
// Build a system where the ws obj is updated every time on navigate, so it already has context
// this way, we can only send broadcast messages to clients that actually have that app / subapp open
handleMessage = (msg, ws) => {
console.log("websocket message received: ", msg)
}
broadcast(event) {
if (!this.wss) return;
let message = JSON.stringify(event)
this.wss.clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
}
}

View File

@@ -0,0 +1,63 @@
css(`
email-join-form {
display: flex
}
`)
export default class EmailJoinForm extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.querySelector('#submit-button').addEventListener('click', () => this.submitEmail());
}
isValidEmail(email) {
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,16}$/;
return emailRegex.test(email);
}
showError(message) {
$(this).find('#form-message')
.css('color', 'red')
.text(message);
}
showSuccess(message) {
$(this).find('#form-message')
.css('color', 'green')
.text(message);
}
clearError() {
this.querySelector('#form-message').textContent = '';
}
async submitEmail() {
const email = this.querySelector('#email-input').value.trim();
this.clearError();
if (!this.isValidEmail(email)) {
this.showError('Please enter a valid email address.');
return;
}
const res = await fetch('/api/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (res.ok) {
this.showSuccess('Email sent.');
} else {
const error = await res.text();
this.showError(error)
}
}
}
customElements.define("email-join-form", EmailJoinForm);

1047
ui/_/code/quill.js Normal file

File diff suppressed because one or more lines are too long

104
ui/_/code/shared.css Normal file
View File

@@ -0,0 +1,104 @@
:root {
--main: var(--brown);
--accent: var(--gold);
--tan: #FFDFB4;
--gold: #F2B36F;
--divider: #bb7c36;
--green: #0857265c;
--red: #BC1C02;
--brown: #812A18;
--darkbrown: #3f0808;
--accent2: var(--green);
}
@media (prefers-color-scheme: dark) {
:root {
--main: var(--brown);
--accent: var(--gold);
--accent2: var(--gold);
}
}
@font-face {
font-family: 'Canterbury';
src: url('/_/fonts/Canterbury/Canterbury.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Bona Nova';
src: url('/_/fonts/BonaNova/BonaNova-Regular.woff') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Bona Nova';
src: url('/_/fonts/BonaNova/BonaNova-Bold.woff') format('truetype');
font-weight: bold;
font-style: normal;
}
body {
font-family: 'Bona Nova', sans-serif;
font-size: 16px;
background-color: var(--main);
color: var(--accent);
}
#title {
padding: 5px 10px;
font-size: 1.7rem;
position: fixed; top: 4.5vh; left: 6vw;
cursor: pointer; z-index: 1;
}
a {
cursor: default;
text-decoration: none;
text-underline-offset: 5px;
transition: background .02s, color .2s;
user-select: none;
color: var(--accent);
display: inline-block; /* makes background and padding behave */
padding: 0.2em 0.5em; /* adds breathing room */
}
a:hover {
text-decoration: none;
background: var(--green);
color: var(--tan);
}
a:active {
background: var(--red); /* background color works now */
}
button {
background-color: transparent;
color: var(--accent);
padding: 0.5em;
box-shadow: none;
border: 1px solid var(--accent);
border-radius: 0.3em;
}
input {
background-color: transparent;
border: 1px solid var(--accent2);
padding-left: 1em;
padding-top: 0.5em;
padding-bottom: 0.5em;
border-radius: 0.3em;
}
input::placeholder {
color: var(--accent)
}
input:focus {
outline: 1px solid var(--red);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<link rel="stylesheet" type="text/css"
href="style.css"/>
</head>
<body>
<h1>Generated from: http://font.download</h1><br/>
<h1 style="font-family:'Bona Nova Regular';font-weight:normal;font-size:42px">AaBbCcDdEeFfGgHhŞşIıİi Example</h1>
<h1 style="font-family:'Bona Nova Italic';font-weight:normal;font-size:42px">AaBbCcDdEeFfGgHhŞşIıİi Example</h1>
<h1 style="font-family:'Bona Nova Bold';font-weight:normal;font-size:42px">AaBbCcDdEeFfGgHhŞşIıİi Example</h1>
</body>
</html>

View File

@@ -0,0 +1,24 @@
/* #### Generated By: http://font.download #### */
@font-face {
font-family: 'Bona Nova Regular';
font-style: normal;
font-weight: normal;
src: local('Bona Nova Regular'), url('BonaNova-Regular.woff') format('woff');
}
@font-face {
font-family: 'Bona Nova Italic';
font-style: normal;
font-weight: normal;
src: local('Bona Nova Italic'), url('BonaNova-Italic.woff') format('woff');
}
@font-face {
font-family: 'Bona Nova Bold';
font-style: normal;
font-weight: normal;
src: local('Bona Nova Bold'), url('BonaNova-Bold.woff') format('woff');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
ui/_/fonts/CrimsonText/OFL.txt Executable file
View File

@@ -0,0 +1,93 @@
Copyright 2010 The Crimson Text Project Authors (https://github.com/googlefonts/Crimson)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,104 @@
import './ForumPanel.js'
css(`
forum- {
font-family: 'Bona';
}
forum- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Forum extends Shadow {
selectedForum = "HY"
render() {
ZStack(() => {
HStack(() => {
VStack(() => {
img("/_/icons/logo.svg", "2em")
.padding(0.8, em)
.borderRadius(12, px)
.marginHorizontal(1, em)
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--darkbrown)"
} else {
this.style.background = ""
}
})
.opacity(0)
img("/_/icons/place/austin.svg", "2em")
.padding(0.8, em)
.borderRadius(12, px)
.marginHorizontal(1, em)
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--darkbrown)"
} else {
this.style.background = ""
}
})
.opacity(0)
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.gap(1, em)
.marginTop(20, vh)
VStack(() => {
ForumPanel()
input("Message Hyperia", "98%")
.paddingVertical(1, em)
.paddingLeft(2, pct)
.color("var(--accent)")
.background("var(--darkbrown)")
.marginBottom(6, em)
.border("none")
.fontSize(1, em)
.onKeyDown(function (e) {
if (e.key === "Enter") {
window.Socket.send({app: "FORUM", operation: "SEND", msg: {forum: "HY", text: this.value }})
this.value = ""
}
})
})
.gap(0.5, em)
.width(100, pct)
.height(100, vh)
.alignHorizontal("center")
.alignVertical("end")
})
.width(100, "%")
.height(87, vh)
.x(0).y(0, vh)
})
.width(100, pct)
.height(100, pct)
}
}
register(Forum)

View File

@@ -0,0 +1,90 @@
import "../../components/LoadingCircle.js"
class ForumPanel extends Shadow {
forums = [
"HY"
]
messages = []
render() {
VStack(() => {
if(this.messages.length > 0) {
let previousDate = null
for(let i=0; i<this.messages.length; i++) {
let message = this.messages[i]
const dateParts = this.parseDate(message.time);
const { date, time } = dateParts;
if (previousDate !== date) {
previousDate = date;
p(date)
.textAlign("center")
.opacity(0.5)
.marginVertical(1, em)
.color("var(--divider)")
}
VStack(() => {
HStack(() => {
p(message.sentBy)
.fontWeight("bold")
.marginBottom(0.3, em)
p(util.formatTime(message.time))
.opacity(0.2)
.marginLeft(1, em)
})
p(message.text)
})
}
} else {
LoadingCircle()
}
})
.gap(1, em)
.position("relative")
.overflow("scroll")
.height(100, pct)
.width(96, pct)
.paddingTop(5, em)
.paddingBottom(2, em)
.paddingLeft(4, pct)
.backgroundColor("var(--darkbrown)")
.onAppear(async () => {
console.log("appear")
requestAnimationFrame(() => {
this.scrollTop = this.scrollHeight
});
let res = await Socket.send({app: "FORUM", operation: "GET", msg: {forum: "HY", number: 100}})
if(!res) console.error("failed to get messages")
if(res.msg.length > 0 && this.messages.length === 0) {
console.log("rerendering", res.msg)
this.messages = res.msg
this.rerender()
}
window.addEventListener("new-post", (e) => {
this.messages = e.detail
if(e.detail.length !== this.messages || e.detail.last.time !== this.messages.last.time || e.detail.first.time !== this.messages.first.time) {
this.rerender()
}
})
})
}
parseDate(str) {
// Format: MM.DD.YYYY-HH:MM:SSxxxxxx(am|pm)
const match = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})-(\d{1,2}):(\d{2}).*(am|pm)$/i);
if (!match) return null;
const [, mm, dd, yyyy, hh, min, ampm] = match;
const date = `${mm}/${dd}/${yyyy}`;
const time = `${hh}:${min}${ampm.toLowerCase()}`;
return { date, time };
}
}
register(ForumPanel)

View File

@@ -0,0 +1,101 @@
import "./JobsSidebar.js"
import "./JobsGrid.js"
css(`
jobs- {
font-family: 'Bona';
}
jobs- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Jobs extends Shadow {
jobs = [
{
title: "Austin Chapter Lead",
salary: "1% of Local Revenue",
company: "Hyperia",
city: "Austin",
state: "TX"
}
]
render() {
ZStack(() => {
HStack(() => {
JobsSidebar()
JobsGrid(this.jobs)
})
.width(100, "%")
.x(0).y(13, vh)
HStack(() => {
input("Search jobs... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ Add Job")
.width(7, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.3px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Jobs)

View File

@@ -0,0 +1,60 @@
class JobsGrid extends Shadow {
jobs;
constructor(jobs) {
super()
this.jobs = jobs
}
boldUntilFirstSpace(text) {
const index = text.indexOf(' ');
if (index === -1) {
// No spaces — bold the whole thing
return `<b>${text}</b>`;
}
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
}
render() {
VStack(() => {
h3("Results")
.marginTop(0.1, em)
.marginBottom(1, em)
.marginLeft(0.4, em)
.color("var(--accent2)")
if (this.jobs.length > 0) {
ZStack(() => {
for (let i = 0; i < this.jobs.length; i++) {
VStack(() => {
p(this.jobs[i].title)
.fontSize(1.2, em)
.fontWeight("bold")
.marginBottom(0.5, em)
p(this.jobs[i].company)
p(this.jobs[i].city + ", " + this.jobs[i].state)
.marginBottom(0.5, em)
p(this.boldUntilFirstSpace(this.jobs[i].salary))
})
.padding(1, em)
.borderRadius(5, "px")
.background("var(--darkbrown)")
}
})
.display("grid")
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
.gap(1, em)
} else {
p("No Jobs!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.width(100, "%")
}
}
register(JobsGrid)

View File

@@ -0,0 +1,26 @@
class JobsSidebar extends Shadow {
render() {
VStack(() => {
h3("Location")
.color("var(--accent2)")
.marginBottom(0, em)
HStack(() => {
input("Location", "100%")
.paddingLeft(3, em)
.paddingVertical(0.75, em)
.backgroundImage("/_/icons/locationPin.svg")
.backgroundRepeat("no-repeat")
.backgroundSize("18px 18px")
.backgroundPosition("10px center")
})
})
.paddingTop(1, em)
.paddingLeft(3, em)
.paddingRight(3, em)
.gap(1, em)
.minWidth(10, vw)
}
}
register(JobsSidebar)

View File

@@ -0,0 +1,105 @@
import "./MarketSidebar.js"
import "./MarketGrid.js"
css(`
market- {
font-family: 'Bona';
}
market- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Market extends Shadow {
listings = [
{
title: "Shield Lapel Pin",
stars: "5",
reviews: 1,
price: "$12",
company: "Hyperia",
type: "new",
image: "/db/images/1",
madeIn: "America"
}
]
render() {
ZStack(() => {
HStack(() => {
MarketSidebar()
MarketGrid(this.listings)
})
.width(100, "%")
.x(0).y(13, vh)
HStack(() => {
input("Search for products... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ Add Item")
.width(7, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Market)

View File

@@ -0,0 +1,140 @@
class MarketGrid extends Shadow {
listings;
constructor(listings) {
super()
this.listings = listings
}
boldUntilFirstSpace(text) {
if(!text) return
const index = text.indexOf(' ');
if (index === -1) {
// No spaces — bold the whole thing
return `<b>${text}</b>`;
}
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
}
render() {
VStack(() => {
h3("Results")
.marginTop(0.1, em)
.marginBottom(1, em)
.marginLeft(0.4, em)
.color("var(--accent)")
.opacity(0.7)
if (this.listings.length > 0) {
ZStack(() => {
// BuyModal()
let params = new URLSearchParams(window.location.search);
const hyperiaMade = params.get("hyperia-made") === "true";
const americaMade = params.get("america-made") === "true";
const newItem = params.get("new") === "true";
const usedItem = params.get("used") === "true";
let filtered = this.listings;
if (hyperiaMade) {
filtered = filtered.filter(item => item.madeIn === "Hyperia");
}
if (americaMade) {
filtered = filtered.filter(item => item.madeIn === "America");
}
if (newItem) {
filtered = filtered.filter(item => item.type === "new");
}
if (usedItem) {
filtered = filtered.filter(item => item.type === "used");
}
for (let i = 0; i < filtered.length; i++) {
const rating = filtered[i].stars
const percent = (rating / 5)
VStack(() => {
img(filtered[i].image)
.marginBottom(0.5, em)
p(filtered[i].company)
.marginBottom(0.5, em)
p(filtered[i].title)
.fontSize(1.2, em)
.fontWeight("bold")
.marginBottom(0.5, em)
HStack(() => {
p(filtered[i].stars)
.marginRight(0.2, em)
ZStack(() => {
div("★★★★★") // Empty stars (background)
.color("#ccc")
div("★★★★★") // Filled stars (foreground, clipped by width)
.color("#ffa500")
.position("absolute")
.top(0)
.left(0)
.whiteSpace("nowrap")
.overflow("hidden")
.width(percent * 5, em)
})
.display("inline-block")
.position("relative")
.fontSize(1.2, em)
.lineHeight(1)
p(filtered[i].reviews)
.marginLeft(0.2, em)
})
.marginBottom(0.5, em)
p(filtered[i].price)
.fontSize(1.75, em)
.marginBottom(0.5, em)
button("Coming Soon!")
.onClick((finished) => {
if(finished) {
}
})
.onHover(function (hovering) {
if(hovering) {
this.style.backgroundColor = "var(--green)"
} else {
this.style.backgroundColor = ""
}
})
})
.padding(1, em)
.border("1px solid var(--accent2)")
.borderRadius(5, "px")
}
})
.display("grid")
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
.gap(1, em)
} else {
p("No Listings!")
}
})
.onQueryChanged(() => {
console.log("query did change yup")
this.rerender()
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.gap(0, em)
.width(100, "%")
}
}
register(MarketGrid)

View File

@@ -0,0 +1,85 @@
class MarketSidebar extends Shadow {
handleChecked(e) {
let checked = e.target.checked
let label = $(`label[for="${e.target.id}"]`).innerText
if(checked) {
window.setQuery(label.toLowerCase(), true)
} else {
window.setQuery(label.toLowerCase(), null)
}
}
render() {
VStack(() => {
p("Make")
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "hyperia-check"
})
.onChange(this.handleChecked)
label("Hyperia-Made")
.attr({
"for": "hyperia-check"
})
.marginLeft(0.5, em)
})
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "america-check"
})
.onChange(this.handleChecked)
label("America-Made")
.attr({
"for": "america-check"
})
.marginLeft(0.5, em)
})
p("Condition")
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "new-check"
})
.onChange(this.handleChecked)
label("New")
.attr({
"for": "new-check"
})
.marginLeft(0.5, em)
})
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "used-check"
})
.onChange(this.handleChecked)
label("Used")
.attr({
"for": "used-check"
})
.marginLeft(0.5, em)
})
})
.paddingTop(12, vh)
.paddingLeft(3, em)
.paddingRight(3, em)
.gap(1, em)
.minWidth(10, vw)
.userSelect('none')
}
}
register(MarketSidebar)

View File

@@ -0,0 +1,188 @@
import "./MessagesSidebar.js"
import "./MessagesPanel.js"
css(`
messages- {
font-family: 'Bona';
}
messages- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Messages extends Shadow {
conversations = []
selectedConvoID = null
onConversationSelect(i) {
console.log("convo selected: ", i)
this.selectedConvoID = i
this.$("messagessidebar-").rerender()
this.$("messagespanel-").rerender()
}
getConvoFromID(id) {
for(let i=0; i<this.conversations.length; i++) {
if(this.conversations[i].id === id) {
return this.conversations[i]
}
}
}
render() {
ZStack(() => {
HStack(() => {
MessagesSidebar(this.conversations, this.selectedConvoID, this.onConversationSelect)
VStack(() => {
if(this.getConvoFromID(this.selectedConvoID)) {
MessagesPanel(this.getConvoFromID(this.selectedConvoID).messages)
} else {
MessagesPanel()
}
input("Send Message", "93%")
.paddingVertical(1, em)
.paddingHorizontal(2, em)
.color("var(--accent)")
.background("var(--darkbrown)")
.marginBottom(6, em)
.border("none")
.fontSize(1, em)
.onKeyDown((e) => {
if (e.key === "Enter") {
window.Socket.send({app: "MESSAGES", operation: "SEND", msg: { conversation: `CONVERSATION-${this.selectedConvoID}`, text: e.target.value }})
e.target.value = ""
}
})
})
.gap(1, em)
.width(100, pct)
.alignHorizontal("center")
.alignVertical("end")
})
.onAppear(async () => {
let res = await Socket.send({app: "MESSAGES", operation: "GET"})
if(!res) console.error("failed to get messages")
if(res.msg.length > 0 && this.conversations.length === 0) {
this.conversations = res.msg
this.selectedConvoID = this.conversations[0].id
this.rerender()
}
window.addEventListener("new-message", (e) => {
let convoID = e.detail.conversationID
let messages = e.detail.messages
let convo = this.getConvoFromID(convoID)
convo.messages = messages
this.rerender()
})
})
.width(100, "%")
.height(87, vh)
.x(0).y(13, vh)
VStack(() => {
p("Add Message")
input("enter email...")
.color("var(--accent)")
.onKeyDown(function (e) {
if (e.key === "Enter") {
window.Socket.send({app: "MESSAGES", operation: "ADDCONVERSATION", msg: {email: this.value }})
this.value = ""
}
})
p("x")
.onClick(function (done) {
if(done) {
this.parentElement.style.display = "none"
}
})
.xRight(2, em).y(2, em)
.fontSize(1.4, em)
.cursor("pointer")
})
.gap(1, em)
.alignVertical("center")
.alignHorizontal("center")
.backgroundColor("black")
.border("1px solid var(--accent)")
.position("fixed")
.x(50, vw).y(50, vh)
.center()
.width(60, vw)
.height(60, vh)
.display("none")
.attr({id: "addPanel"})
HStack(() => {
input("Search messages... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ New Message")
.width(13, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--divider)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((done) => {
console.log("click")
if(done) {
this.$("#addPanel").style.display = "flex"
}
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
}
register(Messages)

View File

@@ -0,0 +1,56 @@
import "../../components/LoadingCircle.js"
class MessagesPanel extends Shadow {
messages
constructor(messages) {
super()
this.messages = messages
}
render() {
VStack(() => {
if(this.messages) {
for(let i=0; i<this.messages.length; i++) {
let message = this.messages[i]
let fromMe = window.profile.email === message.from.email
VStack(() => {
HStack(() => {
p(message.from.firstName + " " + message.from.lastName)
.fontWeight("bold")
.marginBottom(0.3, em)
p(util.formatTime(message.time))
.opacity(0.2)
.marginLeft(1, em)
})
p(message.text)
})
.paddingVertical(0.5, em)
.marginLeft(fromMe ? 70 : 0, pct)
.paddingRight(fromMe ? 10 : 0, pct)
.marginRight(fromMe ? 0 : 70, pct)
.paddingLeft(fromMe ? 5 : 10, pct)
.background(fromMe ? "var(--brown)" : "var(--green)")
}
} else {
LoadingCircle()
}
})
.onAppear(async () => {
requestAnimationFrame(() => {
this.scrollTop = this.scrollHeight
});
})
.gap(1, em)
.position("relative")
.overflow("scroll")
.height(95, pct)
.width(100, pct)
.paddingTop(2, em)
.paddingBottom(2, em)
.backgroundColor("var(--darkbrown)")
}
}
register(MessagesPanel)

View File

@@ -0,0 +1,73 @@
class MessagesSidebar extends Shadow {
conversations = []
selectedConvoID
onSelect
constructor(conversations, selectedConvoID, onSelect) {
super()
this.conversations = conversations
this.selectedConvoID = selectedConvoID
this.onSelect = onSelect
}
render() {
VStack(() => {
this.conversations.forEach((convo, i) => {
VStack(() => {
HStack(() => {
p(this.makeConvoTitle(convo.between))
.textAlign("left")
.marginLeft(0.5, inches)
.paddingTop(0.2, inches)
.width(100, pct)
.marginTop(0)
.fontSize(1, em)
.fontWeight("bold")
p(util.formatTime(convo.messages.last.time))
.paddingTop(0.2, inches)
.fontSize(0.8, em)
.marginRight(0.1, inches)
.color("var(--divider")
})
.justifyContent("space-between")
.marginBottom(0)
p(convo.messages.last.text)
.fontSize(0.8, em)
.textAlign("left")
.marginLeft(0.5, inches)
.marginBottom(2, em)
.color("var(--divider)")
})
.background(convo.id === this.selectedConvoID ? "var(--darkbrown)" : "")
.onClick(() => {
this.onSelect(i)
})
})
})
.minWidth(15, vw)
.height(100, vh)
.gap(0, em)
}
makeConvoTitle(members) {
let membersString = ""
for(let i=0; i<members.length; i++) {
let member = members[i]
if(member.email === window.profile.email) {
continue;
}
if(members.length > 2) {
membersString += member.firstName
} else {
membersString += member.firstName + " " + member.lastName
}
}
return membersString
}
}
register(MessagesSidebar)

View File

@@ -0,0 +1,153 @@
css(`
tasks- {
font-family: 'Bona';
}
tasks- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Tasks extends Shadow {
projects = [
{
"title": "Blockcatcher",
"tasks": {}
}
]
columns = [
{
"title": "backlog",
"tasks": {}
}
]
render() {
ZStack(() => {
HStack(() => {
VStack(() => {
h3("Projects")
.marginTop(0)
.marginBottom(1, em)
.marginLeft(0.4, em)
if (this.projects.length >= 1) {
for(let i = 0; i < this.projects.length; i++) {
p(this.projects[i].title)
}
} else {
p("No Projects!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.borderRight("0.5px solid var(--accent2)")
HStack(() => {
if (this.columns.length >= 1) {
for(let i = 0; i < this.columns.length; i++) {
p(this.columns[i].name)
}
} else {
p("No Conversations!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.borderRight("0.5px solid var(--accent2)")
})
.width(100, "%")
.x(0).y(13, vh)
.borderTop("0.5px solid var(--accent2)")
p("0 Items")
.position("absolute")
.x(50, vw).y(50, vh)
.transform("translate(-50%, -50%)")
HStack(() => {
input("Search tasks...", "45vw")
.attr({
"type": "text"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--accent2)")
.outline("none")
.color("var(--accent)")
.borderRadius(10, px)
button("Search")
.marginLeft(2, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
button("+ New Task")
.width(9, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Tasks)

View File

@@ -0,0 +1,133 @@
css(`
app-menu {
color: var(--tan);
transform: translateX(-50%);
transition: transform .3s;
display: flex; gap: 2em; position: fixed; left: 50vw; bottom: 2em;
}
app-menu.minimized {
color: var(--accent);
transform: translate(-50%, 65%);
border: 0.2px solid var(--accent);
padding-top: 0.5em;
padding-left: 2em;
padding-right: 2em;
padding-bottom: 4em;
bottom: 1em;
border-radius: 12px;
}
app-menu p {
cursor: default;
transition: transform .3s, text-decoration .3s;
padding: 0.5em;
border-radius: 5px;
text-underline-offset: 5px;
}
app-menu p:hover {
text-decoration: underline;
transform: translateY(-5%)
}
app-menu p.touched {
text-decoration: underline;
transform: translateY(0%)
}
app-menu p.selected {
text-decoration: underline;
transform: translateY(-10%)
}
#divider.minimized {
display: none;
}
`)
register(
class AppMenu extends Shadow {
selected;
constructor(selected) {
super()
this.selected = selected
}
render() {
VStack(() => {
HStack(() => {
p("Forum")
p("Messages")
p("Market")
p("Jobs")
})
.justifyContent("center")
.gap(1.5, em)
.paddingRight(2, em)
img("/_/images/divider.svg", "40vw")
.attr({
"id": "divider",
})
})
.gap(0.5, em)
.onNavigate(() => {
if(window.location.pathname === "/") {
this.styleMaximized()
$("app-window").close()
} else {
this.styleMinimized()
$("app-window").open(this.selected)
}
})
.onAppear(() => {
Array.from(this.querySelectorAll("p")).forEach((el) => {
el.addEventListener("mousedown", (e) => {
el.classList.add("touched")
})
})
window.addEventListener("mouseup", (e) => {
let target = e.target
if(!target.matches("app-menu p")) {
return
}
target.classList.remove("touched")
if(target.classList.contains("selected")) {
this.selected = ""
window.navigateTo("/")
} else {
this.selected = target.innerText
window.navigateTo("/app/" + target.innerText.toLowerCase())
}
})
})
if(this.selected) {
this.styleMinimized()
}
}
styleMaximized() {
$$("app-menu p").forEach((el) => {
el.classList.remove("selected")
})
this.classList.remove("minimized")
$("#divider").style.display = ""
}
styleMinimized() {
$$("app-menu p").forEach((el) => {
if(el.innerText !== this.selected) {
el.classList.remove("selected")
} else {
el.classList.add("selected")
}
})
this.classList.add("minimized")
$("#divider").style.display = "none"
}
}
, "app-menu")

View File

@@ -0,0 +1,54 @@
import "../apps/Forum/Forum.js"
import "../apps/Tasks/Tasks.js"
import "../apps/Messages/Messages.js"
import "../apps/Market/Market.js"
import "../apps/Jobs/Jobs.js"
class AppWindow extends Shadow {
app;
constructor(app) {
super()
this.app = app
}
render() {
ZStack(() => {
switch(this.app) {
case "Forum":
Forum()
break;
case "Messages":
Messages()
break;
case "Market":
Market()
break;
case "Jobs":
Jobs()
break;
}
})
.position("fixed")
.display(this.app ? 'block' : 'none')
.width(100, "vw")
.height(100, "vh")
.background("#591d10")
.x(0)
.y(0)
// .backgroundImage("/_/images/fabric.png")
// .backgroundSize("33vw auto")
}
open(app) {
this.app = app
this.rerender()
}
close() {
this.style.display = "none"
}
}
register(AppWindow, "app-window")

View File

@@ -0,0 +1,91 @@
import "./AppWindow.js"
import "./AppMenu.js"
import "./ProfileButton.js"
import "./InputBox.js"
import "./Sidebar.js"
class Home extends Shadow {
render() {
ZStack(() => {
img("/_/icons/logo.svg", "2.5em")
.position("fixed")
.left(3, em)
.top(3, vh)
.zIndex(3)
.onClick(() => {
window.navigateTo("/")
})
div()
.width(100, vw)
.height(100, vh)
.margin(0)
.backgroundImage("/_/images/the_return.webp")
.backgroundSize("cover")
.backgroundPosition("48% 65%")
.backgroundRepeat("no-repeat")
switch(window.location.pathname) {
case "/":
AppWindow()
AppMenu()
break
case "/app/jobs":
AppWindow("Jobs")
AppMenu("Jobs")
break;
case "/app/messages":
AppWindow("Messages")
AppMenu("Messages")
break;
case "/app/market":
AppWindow("Market")
AppMenu("Market")
break;
case "/app/forum":
AppWindow("Forum")
AppMenu("Forum")
break;
default:
throw new Error("Unknown route!")
}
HStack(() => {
ProfileButton()
.zIndex(1)
.cursor("default")
a("/signout", "Sign Out")
.background("transparent")
.border(window.location.pathname === "/" ? "1px solid var(--tan)" : "0.5px solid #bb7c36")
.color(window.location.pathname === "/" ? "var(--tan)" : "var(--accent)")
.borderRadius(5, px)
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = ""
}
})
.onNavigate(function () {
if(window.location.pathname === "/") {
this.style.border = "1px solid var(--tan)"
this.style.color = "var(--tan)"
} else {
this.style.border = "0.5px solid #bb7c36"
this.style.color = "var(--accent)"
}
})
})
.gap(1, em)
.xRight(2, em).y(2.3, em)
.position("fixed")
.alignVertical("center")
})
}
}
register(Home)

View File

@@ -0,0 +1,54 @@
css(`
input-box {
display: block;
width: 60vw;
position: fixed;
left: 20vw;
bottom: 2vw;
}
.input {
width: 100%;
background-color: var(--accent2);
opacity: 0.5;
border-radius: 12px;
border: none;
resize: none;
color: var(--orange);
padding: 1em;
height: 1em;
outline: none;
transition: opacity .1s, scale .1s
}
.input:focus {
opacity: 80%;
scale: 1.02
}
`)
export default class InputBox extends HTMLElement {
hovered = false
connectedCallback() {
this.render()
this.addListeners()
}
render() {
this.innerHTML = /* html */`
<textarea class="input"></textarea>
`
}
addListeners() {
this.$("textarea").addEventListener("keydown", (e) => {
if(e.key === "Enter") {
e.preventDefault()
e.target.blur()
}
})
}
}
customElements.define("input-box", InputBox)

View File

@@ -0,0 +1,25 @@
class LoadingCircle extends Shadow {
render() {
div()
.borderRadius(100, pct)
.width(2, em).height(2, em)
.x(45, pct).y(50, pct)
.center()
.backgroundColor("var(--accent")
.transition("transform 1.75s ease-in-out")
.onAppear(function () {
let growing = true;
setInterval(() => {
if (growing) {
this.style.transform = "scale(1.5)";
} else {
this.style.transform = "scale(0.7)";
}
growing = !growing;
}, 750);
});
}
}
register(LoadingCircle)

View File

@@ -0,0 +1,43 @@
import "./ProfileMenu.js"
class ProfileButton extends Shadow {
async render() {
ZStack(async () => {
img("/_/icons/profile.svg", "1.5em", "1.5em")
.backgroundColor("var(--accent)")
.padding(0.2, em)
.borderRadius(5, px)
ProfileMenu()
})
.display("block")
.onAppear(() => {
window.addEventListener("mousedown", (e) => { // bad - adding every time it renders
if(!e.target.closest("profilebutton-")) {
this.$("profile-menu").style.display = "none"
}
})
})
.onHover((hovering, e) => {
console.log(hovering)
console.log(e.target)
if(hovering && !e.target.closest("profile-menu")) {
this.$("img").backgroundColor("var(--accent)")
this.$("img").style.outline = "1px solid black"
} else if(!e.target.closest("profile-menu")) {
this.$("img").backgroundColor("")
this.$("img").style.outline = ""
}
})
.onClick((done) => {
console.log(done)
if(done) {
this.$("profile-menu").style.display = ""
}
})
}
}
register(ProfileButton)

View File

@@ -0,0 +1,68 @@
class ProfileMenu extends Shadow {
render() {
VStack(() => {
h2("Profile")
HStack(() => {
p("Email: ")
.fontWeight("bold")
p(window.profile?.email)
})
.gap(1, em)
HStack(() => {
p("Name: ")
.fontWeight("bold")
p(window.profile?.name)
})
.gap(1, em)
p("X")
.onClick(() => {
this.style.display = "none"
})
.xRight(2, em).y(1, em)
})
.paddingLeft(1, em)
.color("var(--accent)")
.position("fixed")
.border("1px solid var(--accent)")
.x(50, vw).y(47, vh)
.width(70, vw)
.height(70, vh)
.backgroundColor("black")
.center()
.display("none")
.onAppear(async () => {
if(!window.profile) {
window.profile = await this.fetchProfile()
this.rerender()
}
})
}
async fetchProfile() {
try {
const res = await fetch("/profile", {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json"
}
});
if (!res.ok) throw new Error("Failed to fetch profile");
const profile = await res.json();
console.log(profile);
return profile
} catch (err) {
console.error(err);
}
}
}
register(ProfileMenu, "profile-menu")

View File

@@ -0,0 +1,39 @@
css(`
side-bar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 16vw;
border-right: 0.5px solid var(--accent2);
display: flex;
flex-direction: column;
padding-top: 13vh;
}
side-bar button {
color: var(--darkbrown);
margin: 1.5em;
background-color: color-mix(in srgb, var(--accent2) 35%, var(--orange) 65%);
border: 1px solid var(--orange);
border-radius: 12px;
padding: 0.5em;
font-weight: bold;
}
`)
class Sidebar extends HTMLElement {
connectedCallback() {
this.render()
}
render() {
this.innerHTML = /* html */ `
<span id="title" style="position: absolute; left: 50%; transform: translateX(-50%) " class="link" onclick='window.location.href="/"'>hyperia</span>
<button>Main</button>
`
}
}
customElements.define("side-bar", Sidebar)

14
ui/desktop/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hyperia</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/_/icons/logo.svg">
<link rel="stylesheet" href="/_/code/shared.css">
<script src="/_/code/quill.js"></script>
<script src="/_/code/zod.js"></script>
<script type="module" src="75820185/index.js"></script>
</head>
<body style="margin: 0px">
</body>
</html>

8
ui/desktop/index.js Normal file
View File

@@ -0,0 +1,8 @@
import Socket from "./ws/Socket.js"
import "./components/Home.js"
import util from "./util.js"
window.util = util
window.Socket = new Socket()
Home()

9
ui/desktop/util.js Normal file
View File

@@ -0,0 +1,9 @@
export default class util {
static formatTime(str) {
const match = str.match(/-(\d+:\d+):\d+.*(am|pm)/i);
if (!match) return null;
const [_, hourMin, ampm] = match;
return hourMin + ampm.toLowerCase();
}
}

View File

@@ -0,0 +1,62 @@
class Connection {
connectionTries = 0
ws;
linkCreated;
wsStatus;
constructor(receiveCB) {
this.init()
this.receiveCB = receiveCB
}
init() {
if(window.location.hostname === "localhost") {
this.ws = new WebSocket("ws://" + "localhost:3003")
} else {
this.ws = new WebSocket("wss://" + window.location.hostname + window.location.pathname)
}
this.ws.addEventListener('open', () => {
this.connectionTries = 0
console.log("Websocket connection established.");
this.ws.addEventListener('message', this.receiveCB)
});
this.ws.addEventListener("close", () => {
this.checkOpen();
console.log('Websocket Closed')
})
}
async checkOpen() {
if (this.ws.readyState === WebSocket.OPEN) {
return true
} else {
await this.sleep(this.connectionTries < 20 ? 5000 : 60000)
this.connectionTries++
console.log('Reestablishing connection')
this.init()
}
}
sleep = (time) => {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
send = (msg) => {
console.log("sending")
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(msg);
}
else if(this.connectionTries === 0) {
setTimeout(() => {
this.send(msg)
}, 100)
}
else {
console.error('No websocket connection: Cannot send message');
}
}
}
export default Connection

45
ui/desktop/ws/Socket.js Normal file
View File

@@ -0,0 +1,45 @@
import Connection from "./Connection.js";
export default class Socket {
connection;
disabled = true;
requestID = 1;
pending = new Map();
constructor() {
this.connection = new Connection(this.receive);
}
isOpen() {
if(this.connection.checkOpen()) {
return true;
} else {
return false;
}
}
send(msg) {
return new Promise(resolve => {
const id = (++this.requestID).toString();
this.pending.set(id, resolve);
this.connection.send(JSON.stringify({ id, ...msg }));
});
}
receive = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && this.pending.has(msg.id)) {
this.pending.get(msg.id)(msg);
this.pending.delete(msg.id);
return;
} else {
this.onBroadcast(msg)
}
}
onBroadcast(msg) {
window.dispatchEvent(new CustomEvent(msg.event, {
detail: msg.msg
}));
}
}

View File

@@ -0,0 +1,104 @@
import './ForumPanel.js'
css(`
forum- {
font-family: 'Bona';
}
forum- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Forum extends Shadow {
selectedForum = "HY"
render() {
ZStack(() => {
HStack(() => {
VStack(() => {
img("/_/icons/logo.svg", "2em")
.padding(0.8, em)
.borderRadius(12, px)
.marginHorizontal(1, em)
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--darkbrown)"
} else {
this.style.background = ""
}
})
.opacity(0)
img("/_/icons/place/austin.svg", "2em")
.padding(0.8, em)
.borderRadius(12, px)
.marginHorizontal(1, em)
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--darkbrown)"
} else {
this.style.background = ""
}
})
.opacity(0)
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.gap(1, em)
.marginTop(20, vh)
VStack(() => {
ForumPanel()
input("Message Hyperia", "98%")
.paddingVertical(1, em)
.paddingLeft(2, pct)
.color("var(--accent)")
.background("var(--darkbrown)")
.marginBottom(6, em)
.border("none")
.fontSize(1, em)
.onKeyDown(function (e) {
if (e.key === "Enter") {
window.Socket.send({app: "FORUM", operation: "SEND", msg: {forum: "HY", text: this.value }})
this.value = ""
}
})
})
.gap(0.5, em)
.width(100, pct)
.height(100, vh)
.alignHorizontal("center")
.alignVertical("end")
})
.width(100, "%")
.height(87, vh)
.x(0).y(0, vh)
})
.width(100, pct)
.height(100, pct)
}
}
register(Forum)

View File

@@ -0,0 +1,90 @@
import "../../components/LoadingCircle.js"
class ForumPanel extends Shadow {
forums = [
"HY"
]
messages = []
render() {
VStack(() => {
if(this.messages.length > 0) {
let previousDate = null
for(let i=0; i<this.messages.length; i++) {
let message = this.messages[i]
const dateParts = this.parseDate(message.time);
const { date, time } = dateParts;
if (previousDate !== date) {
previousDate = date;
p(date)
.textAlign("center")
.opacity(0.5)
.marginVertical(1, em)
.color("var(--divider)")
}
VStack(() => {
HStack(() => {
p(message.sentBy)
.fontWeight("bold")
.marginBottom(0.3, em)
p(util.formatTime(message.time))
.opacity(0.2)
.marginLeft(1, em)
})
p(message.text)
})
}
} else {
LoadingCircle()
}
})
.gap(1, em)
.position("relative")
.overflow("scroll")
.height(100, pct)
.width(96, pct)
.paddingTop(5, em)
.paddingBottom(2, em)
.paddingLeft(4, pct)
.backgroundColor("var(--darkbrown)")
.onAppear(async () => {
console.log("appear")
requestAnimationFrame(() => {
this.scrollTop = this.scrollHeight
});
let res = await Socket.send({app: "FORUM", operation: "GET", msg: {forum: "HY", number: 100}})
if(!res) console.error("failed to get messages")
if(res.msg.length > 0 && this.messages.length === 0) {
console.log("rerendering", res.msg)
this.messages = res.msg
this.rerender()
}
window.addEventListener("new-post", (e) => {
this.messages = e.detail
if(e.detail.length !== this.messages || e.detail.last.time !== this.messages.last.time || e.detail.first.time !== this.messages.first.time) {
this.rerender()
}
})
})
}
parseDate(str) {
// Format: MM.DD.YYYY-HH:MM:SSxxxxxx(am|pm)
const match = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})-(\d{1,2}):(\d{2}).*(am|pm)$/i);
if (!match) return null;
const [, mm, dd, yyyy, hh, min, ampm] = match;
const date = `${mm}/${dd}/${yyyy}`;
const time = `${hh}:${min}${ampm.toLowerCase()}`;
return { date, time };
}
}
register(ForumPanel)

101
ui/mobile/apps/Jobs/Jobs.js Normal file
View File

@@ -0,0 +1,101 @@
import "./JobsSidebar.js"
import "./JobsGrid.js"
css(`
jobs- {
font-family: 'Bona';
}
jobs- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Jobs extends Shadow {
jobs = [
{
title: "Austin Chapter Lead",
salary: "1% of Local Revenue",
company: "Hyperia",
city: "Austin",
state: "TX"
}
]
render() {
ZStack(() => {
HStack(() => {
JobsSidebar()
JobsGrid(this.jobs)
})
.width(100, "%")
.x(0).y(13, vh)
HStack(() => {
input("Search jobs... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ Add Job")
.width(7, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.3px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Jobs)

View File

@@ -0,0 +1,60 @@
class JobsGrid extends Shadow {
jobs;
constructor(jobs) {
super()
this.jobs = jobs
}
boldUntilFirstSpace(text) {
const index = text.indexOf(' ');
if (index === -1) {
// No spaces — bold the whole thing
return `<b>${text}</b>`;
}
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
}
render() {
VStack(() => {
h3("Results")
.marginTop(0.1, em)
.marginBottom(1, em)
.marginLeft(0.4, em)
.color("var(--accent2)")
if (this.jobs.length > 0) {
ZStack(() => {
for (let i = 0; i < this.jobs.length; i++) {
VStack(() => {
p(this.jobs[i].title)
.fontSize(1.2, em)
.fontWeight("bold")
.marginBottom(0.5, em)
p(this.jobs[i].company)
p(this.jobs[i].city + ", " + this.jobs[i].state)
.marginBottom(0.5, em)
p(this.boldUntilFirstSpace(this.jobs[i].salary))
})
.padding(1, em)
.borderRadius(5, "px")
.background("var(--darkbrown)")
}
})
.display("grid")
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
.gap(1, em)
} else {
p("No Jobs!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.width(100, "%")
}
}
register(JobsGrid)

View File

@@ -0,0 +1,26 @@
class JobsSidebar extends Shadow {
render() {
VStack(() => {
h3("Location")
.color("var(--accent2)")
.marginBottom(0, em)
HStack(() => {
input("Location", "100%")
.paddingLeft(3, em)
.paddingVertical(0.75, em)
.backgroundImage("/_/icons/locationPin.svg")
.backgroundRepeat("no-repeat")
.backgroundSize("18px 18px")
.backgroundPosition("10px center")
})
})
.paddingTop(1, em)
.paddingLeft(3, em)
.paddingRight(3, em)
.gap(1, em)
.minWidth(10, vw)
}
}
register(JobsSidebar)

View File

@@ -0,0 +1,105 @@
import "./MarketSidebar.js"
import "./MarketGrid.js"
css(`
market- {
font-family: 'Bona';
}
market- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Market extends Shadow {
listings = [
{
title: "Shield Lapel Pin",
stars: "5",
reviews: 1,
price: "$12",
company: "Hyperia",
type: "new",
image: "/db/images/1",
madeIn: "America"
}
]
render() {
ZStack(() => {
HStack(() => {
MarketSidebar()
MarketGrid(this.listings)
})
.width(100, "%")
.x(0).y(13, vh)
HStack(() => {
input("Search for products... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ Add Item")
.width(7, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Market)

View File

@@ -0,0 +1,140 @@
class MarketGrid extends Shadow {
listings;
constructor(listings) {
super()
this.listings = listings
}
boldUntilFirstSpace(text) {
if(!text) return
const index = text.indexOf(' ');
if (index === -1) {
// No spaces — bold the whole thing
return `<b>${text}</b>`;
}
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
}
render() {
VStack(() => {
h3("Results")
.marginTop(0.1, em)
.marginBottom(1, em)
.marginLeft(0.4, em)
.color("var(--accent)")
.opacity(0.7)
if (this.listings.length > 0) {
ZStack(() => {
// BuyModal()
let params = new URLSearchParams(window.location.search);
const hyperiaMade = params.get("hyperia-made") === "true";
const americaMade = params.get("america-made") === "true";
const newItem = params.get("new") === "true";
const usedItem = params.get("used") === "true";
let filtered = this.listings;
if (hyperiaMade) {
filtered = filtered.filter(item => item.madeIn === "Hyperia");
}
if (americaMade) {
filtered = filtered.filter(item => item.madeIn === "America");
}
if (newItem) {
filtered = filtered.filter(item => item.type === "new");
}
if (usedItem) {
filtered = filtered.filter(item => item.type === "used");
}
for (let i = 0; i < filtered.length; i++) {
const rating = filtered[i].stars
const percent = (rating / 5)
VStack(() => {
img(filtered[i].image)
.marginBottom(0.5, em)
p(filtered[i].company)
.marginBottom(0.5, em)
p(filtered[i].title)
.fontSize(1.2, em)
.fontWeight("bold")
.marginBottom(0.5, em)
HStack(() => {
p(filtered[i].stars)
.marginRight(0.2, em)
ZStack(() => {
div("★★★★★") // Empty stars (background)
.color("#ccc")
div("★★★★★") // Filled stars (foreground, clipped by width)
.color("#ffa500")
.position("absolute")
.top(0)
.left(0)
.whiteSpace("nowrap")
.overflow("hidden")
.width(percent * 5, em)
})
.display("inline-block")
.position("relative")
.fontSize(1.2, em)
.lineHeight(1)
p(filtered[i].reviews)
.marginLeft(0.2, em)
})
.marginBottom(0.5, em)
p(filtered[i].price)
.fontSize(1.75, em)
.marginBottom(0.5, em)
button("Coming Soon!")
.onClick((finished) => {
if(finished) {
}
})
.onHover(function (hovering) {
if(hovering) {
this.style.backgroundColor = "var(--green)"
} else {
this.style.backgroundColor = ""
}
})
})
.padding(1, em)
.border("1px solid var(--accent2)")
.borderRadius(5, "px")
}
})
.display("grid")
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
.gap(1, em)
} else {
p("No Listings!")
}
})
.onQueryChanged(() => {
console.log("query did change yup")
this.rerender()
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.gap(0, em)
.width(100, "%")
}
}
register(MarketGrid)

View File

@@ -0,0 +1,85 @@
class MarketSidebar extends Shadow {
handleChecked(e) {
let checked = e.target.checked
let label = $(`label[for="${e.target.id}"]`).innerText
if(checked) {
window.setQuery(label.toLowerCase(), true)
} else {
window.setQuery(label.toLowerCase(), null)
}
}
render() {
VStack(() => {
p("Make")
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "hyperia-check"
})
.onChange(this.handleChecked)
label("Hyperia-Made")
.attr({
"for": "hyperia-check"
})
.marginLeft(0.5, em)
})
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "america-check"
})
.onChange(this.handleChecked)
label("America-Made")
.attr({
"for": "america-check"
})
.marginLeft(0.5, em)
})
p("Condition")
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "new-check"
})
.onChange(this.handleChecked)
label("New")
.attr({
"for": "new-check"
})
.marginLeft(0.5, em)
})
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "used-check"
})
.onChange(this.handleChecked)
label("Used")
.attr({
"for": "used-check"
})
.marginLeft(0.5, em)
})
})
.paddingTop(12, vh)
.paddingLeft(3, em)
.paddingRight(3, em)
.gap(1, em)
.minWidth(10, vw)
.userSelect('none')
}
}
register(MarketSidebar)

View File

@@ -0,0 +1,188 @@
import "./MessagesSidebar.js"
import "./MessagesPanel.js"
css(`
messages- {
font-family: 'Bona';
}
messages- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Messages extends Shadow {
conversations = []
selectedConvoID = null
onConversationSelect(i) {
console.log("convo selected: ", i)
this.selectedConvoID = i
this.$("messagessidebar-").rerender()
this.$("messagespanel-").rerender()
}
getConvoFromID(id) {
for(let i=0; i<this.conversations.length; i++) {
if(this.conversations[i].id === id) {
return this.conversations[i]
}
}
}
render() {
ZStack(() => {
HStack(() => {
MessagesSidebar(this.conversations, this.selectedConvoID, this.onConversationSelect)
VStack(() => {
if(this.getConvoFromID(this.selectedConvoID)) {
MessagesPanel(this.getConvoFromID(this.selectedConvoID).messages)
} else {
MessagesPanel()
}
input("Send Message", "93%")
.paddingVertical(1, em)
.paddingHorizontal(2, em)
.color("var(--accent)")
.background("var(--darkbrown)")
.marginBottom(6, em)
.border("none")
.fontSize(1, em)
.onKeyDown((e) => {
if (e.key === "Enter") {
window.Socket.send({app: "MESSAGES", operation: "SEND", msg: { conversation: `CONVERSATION-${this.selectedConvoID}`, text: e.target.value }})
e.target.value = ""
}
})
})
.gap(1, em)
.width(100, pct)
.alignHorizontal("center")
.alignVertical("end")
})
.onAppear(async () => {
let res = await Socket.send({app: "MESSAGES", operation: "GET"})
if(!res) console.error("failed to get messages")
if(res.msg.length > 0 && this.conversations.length === 0) {
this.conversations = res.msg
this.selectedConvoID = this.conversations[0].id
this.rerender()
}
window.addEventListener("new-message", (e) => {
let convoID = e.detail.conversationID
let messages = e.detail.messages
let convo = this.getConvoFromID(convoID)
convo.messages = messages
this.rerender()
})
})
.width(100, "%")
.height(87, vh)
.x(0).y(13, vh)
VStack(() => {
p("Add Message")
input("enter email...")
.color("var(--accent)")
.onKeyDown(function (e) {
if (e.key === "Enter") {
window.Socket.send({app: "MESSAGES", operation: "ADDCONVERSATION", msg: {email: this.value }})
this.value = ""
}
})
p("x")
.onClick(function (done) {
if(done) {
this.parentElement.style.display = "none"
}
})
.xRight(2, em).y(2, em)
.fontSize(1.4, em)
.cursor("pointer")
})
.gap(1, em)
.alignVertical("center")
.alignHorizontal("center")
.backgroundColor("black")
.border("1px solid var(--accent)")
.position("fixed")
.x(50, vw).y(50, vh)
.center()
.width(60, vw)
.height(60, vh)
.display("none")
.attr({id: "addPanel"})
HStack(() => {
input("Search messages... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ New Message")
.width(13, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--divider)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((done) => {
console.log("click")
if(done) {
this.$("#addPanel").style.display = "flex"
}
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
}
register(Messages)

View File

@@ -0,0 +1,56 @@
import "../../components/LoadingCircle.js"
class MessagesPanel extends Shadow {
messages
constructor(messages) {
super()
this.messages = messages
}
render() {
VStack(() => {
if(this.messages) {
for(let i=0; i<this.messages.length; i++) {
let message = this.messages[i]
let fromMe = window.profile.email === message.from.email
VStack(() => {
HStack(() => {
p(message.from.firstName + " " + message.from.lastName)
.fontWeight("bold")
.marginBottom(0.3, em)
p(util.formatTime(message.time))
.opacity(0.2)
.marginLeft(1, em)
})
p(message.text)
})
.paddingVertical(0.5, em)
.marginLeft(fromMe ? 70 : 0, pct)
.paddingRight(fromMe ? 10 : 0, pct)
.marginRight(fromMe ? 0 : 70, pct)
.paddingLeft(fromMe ? 5 : 10, pct)
.background(fromMe ? "var(--brown)" : "var(--green)")
}
} else {
LoadingCircle()
}
})
.onAppear(async () => {
requestAnimationFrame(() => {
this.scrollTop = this.scrollHeight
});
})
.gap(1, em)
.position("relative")
.overflow("scroll")
.height(95, pct)
.width(100, pct)
.paddingTop(2, em)
.paddingBottom(2, em)
.backgroundColor("var(--darkbrown)")
}
}
register(MessagesPanel)

View File

@@ -0,0 +1,73 @@
class MessagesSidebar extends Shadow {
conversations = []
selectedConvoID
onSelect
constructor(conversations, selectedConvoID, onSelect) {
super()
this.conversations = conversations
this.selectedConvoID = selectedConvoID
this.onSelect = onSelect
}
render() {
VStack(() => {
this.conversations.forEach((convo, i) => {
VStack(() => {
HStack(() => {
p(this.makeConvoTitle(convo.between))
.textAlign("left")
.marginLeft(0.5, inches)
.paddingTop(0.2, inches)
.width(100, pct)
.marginTop(0)
.fontSize(1, em)
.fontWeight("bold")
p(util.formatTime(convo.messages.last.time))
.paddingTop(0.2, inches)
.fontSize(0.8, em)
.marginRight(0.1, inches)
.color("var(--divider")
})
.justifyContent("space-between")
.marginBottom(0)
p(convo.messages.last.text)
.fontSize(0.8, em)
.textAlign("left")
.marginLeft(0.5, inches)
.marginBottom(2, em)
.color("var(--divider)")
})
.background(convo.id === this.selectedConvoID ? "var(--darkbrown)" : "")
.onClick(() => {
this.onSelect(i)
})
})
})
.minWidth(15, vw)
.height(100, vh)
.gap(0, em)
}
makeConvoTitle(members) {
let membersString = ""
for(let i=0; i<members.length; i++) {
let member = members[i]
if(member.email === window.profile.email) {
continue;
}
if(members.length > 2) {
membersString += member.firstName
} else {
membersString += member.firstName + " " + member.lastName
}
}
return membersString
}
}
register(MessagesSidebar)

View File

@@ -0,0 +1,153 @@
css(`
tasks- {
font-family: 'Bona';
}
tasks- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Tasks extends Shadow {
projects = [
{
"title": "Blockcatcher",
"tasks": {}
}
]
columns = [
{
"title": "backlog",
"tasks": {}
}
]
render() {
ZStack(() => {
HStack(() => {
VStack(() => {
h3("Projects")
.marginTop(0)
.marginBottom(1, em)
.marginLeft(0.4, em)
if (this.projects.length >= 1) {
for(let i = 0; i < this.projects.length; i++) {
p(this.projects[i].title)
}
} else {
p("No Projects!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.borderRight("0.5px solid var(--accent2)")
HStack(() => {
if (this.columns.length >= 1) {
for(let i = 0; i < this.columns.length; i++) {
p(this.columns[i].name)
}
} else {
p("No Conversations!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.borderRight("0.5px solid var(--accent2)")
})
.width(100, "%")
.x(0).y(13, vh)
.borderTop("0.5px solid var(--accent2)")
p("0 Items")
.position("absolute")
.x(50, vw).y(50, vh)
.transform("translate(-50%, -50%)")
HStack(() => {
input("Search tasks...", "45vw")
.attr({
"type": "text"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--accent2)")
.outline("none")
.color("var(--accent)")
.borderRadius(10, px)
button("Search")
.marginLeft(2, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
button("+ New Task")
.width(9, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Tasks)

View File

@@ -0,0 +1,22 @@
class AppMenu extends Shadow {
render() {
HStack(() => {
img("/_/icons/mail.png", "2em", "2em")
img("/_/icons/Column.svg", "2em", "2em")
p("S")
p("S")
p("S")
})
.borderTop("1px solid black")
.height(3, em)
.x(0).yBottom(0)
.justifyContent("space-between")
.paddingHorizontal(2, em)
.paddingVertical(1, em)
.transform("translateY(-50%)")
.width(100, vw)
.boxSizing("border-box")
}
}
register(AppMenu)

View File

@@ -0,0 +1,12 @@
import "./AppMenu.js"
class Home extends Shadow {
render() {
ZStack(() => {
AppMenu()
})
}
}
register(Home)

14
ui/mobile/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hyperia</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/_/icons/logo.svg">
<link rel="stylesheet" href="/_/code/shared.css">
<script src="/_/code/quill.js"></script>
<script src="/_/code/zod.js"></script>
<script type="module" src="75820185/index.js"></script>
</head>
<body style="margin: 0px">
</body>
</html>

8
ui/mobile/index.js Normal file
View File

@@ -0,0 +1,8 @@
import Socket from "./ws/Socket.js"
import "./components/Home.js"
import util from "./util.js"
window.util = util
window.Socket = new Socket()
Home()

9
ui/mobile/util.js Normal file
View File

@@ -0,0 +1,9 @@
export default class util {
static formatTime(str) {
const match = str.match(/-(\d+:\d+):\d+.*(am|pm)/i);
if (!match) return null;
const [_, hourMin, ampm] = match;
return hourMin + ampm.toLowerCase();
}
}

View File

@@ -0,0 +1,62 @@
class Connection {
connectionTries = 0
ws;
linkCreated;
wsStatus;
constructor(receiveCB) {
this.init()
this.receiveCB = receiveCB
}
init() {
if(window.location.hostname === "localhost") {
this.ws = new WebSocket("ws://" + "localhost:3003")
} else {
this.ws = new WebSocket("wss://" + window.location.hostname + window.location.pathname)
}
this.ws.addEventListener('open', () => {
this.connectionTries = 0
console.log("Websocket connection established.");
this.ws.addEventListener('message', this.receiveCB)
});
this.ws.addEventListener("close", () => {
this.checkOpen();
console.log('Websocket Closed')
})
}
async checkOpen() {
if (this.ws.readyState === WebSocket.OPEN) {
return true
} else {
await this.sleep(this.connectionTries < 20 ? 5000 : 60000)
this.connectionTries++
console.log('Reestablishing connection')
this.init()
}
}
sleep = (time) => {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
send = (msg) => {
console.log("sending")
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(msg);
}
else if(this.connectionTries === 0) {
setTimeout(() => {
this.send(msg)
}, 100)
}
else {
console.error('No websocket connection: Cannot send message');
}
}
}
export default Connection

45
ui/mobile/ws/Socket.js Normal file
View File

@@ -0,0 +1,45 @@
import Connection from "./Connection.js";
export default class Socket {
connection;
disabled = true;
requestID = 1;
pending = new Map();
constructor() {
this.connection = new Connection(this.receive);
}
isOpen() {
if(this.connection.checkOpen()) {
return true;
} else {
return false;
}
}
send(msg) {
return new Promise(resolve => {
const id = (++this.requestID).toString();
this.pending.set(id, resolve);
this.connection.send(JSON.stringify({ id, ...msg }));
});
}
receive = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && this.pending.has(msg.id)) {
this.pending.get(msg.id)(msg);
this.pending.delete(msg.id);
return;
} else {
this.onBroadcast(msg)
}
}
onBroadcast(msg) {
window.dispatchEvent(new CustomEvent(msg.event, {
detail: msg.msg
}));
}
}

View File

@@ -0,0 +1,16 @@
css(`
page-footer {
display: flex;
justify-content: flex-end;
}
`)
export default class Footer extends HTMLElement {
connectedCallback() {
this.innerHTML += /* html */`
`
}
}
customElements.define("page-footer", Footer)

View File

@@ -0,0 +1,55 @@
class NavBar extends Shadow {
NavButton(text) {
function normalizeText(text) {
return text.toLowerCase().replace(/[\s?]+/g, "");
}
function isSelected(el) {
return ("/" + normalizeText(el.innerText)) === window.location.pathname
}
return p(text)
.cursor("default")
.textUnderlineOffset(0.5, em)
.onAppear(function() {
this.style.textDecoration = isSelected(this) ? "underline" : ""
})
.onHover(function (hovering) {
if(hovering) {
this.style.textDecoration = "underline"
} else if(!isSelected(this)) {
this.style.textDecoration = ""
}
})
.onClick(function (done) {
if(done) {
if(!isSelected(this)) {
window.navigateTo(normalizeText(this.innerText))
} else {
window.navigateTo("/")
}
}
})
}
render() {
HStack(() => {
this.NavButton("WHY?")
this.NavButton("EVENTS")
div().width(2.5, em).height(2.5, em)
this.NavButton("JOIN")
this.NavButton("SIGN IN")
})
.x(50, vw).y(4, em)
.center()
.fontSize(0.85, em)
.justifyContent("center")
.gap(3, em)
.paddingRight(2, em)
.width(50, vw)
}
}
register(NavBar)

View File

@@ -0,0 +1,27 @@
css(`
nav-menu {
position: fixed;
bottom: 6vh;
right: 6vh;
width: 20vw;
height: 10vh;
background: var(--green);
color: var(--tan);
border: 20px solid var(--tan);
}
`)
export default class NavMenu extends HTMLElement {
connectedCallback() {
this.innerHTML += /* html */ `
<span style="display: block; height: 2vh; background-color: var(--tan); border-radius: 2px;"></span>
<span style="display: block; height: 2vh; background-color: var(--tan); border-radius: 2px;"></span>
<span style="display: block; height: 2vh; background-color: var(--tan); border-radius: 2px;"></span>
<p style="font-size: 3vh; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%)">Menu</p>
`
document.addEventListener("")
}
}
customElements.define("nav-menu", NavMenu)

View File

@@ -0,0 +1,46 @@
css(`
side-bar {
position: fixed;
top: 0;
right: -100vw;
width: 80vw;
height: 100vh;
background: var(--tan);
border-left: 1px solid var(--green);
transition: right 0.3s ease;
z-index: 10;
padding: 5vw;
}
side-bar a {
font-size: 8vw;
color: var(--red)
}
side-bar h2 {
font-size: 6vw
}
`)
export default class SideBar extends HTMLElement {
connectedCallback() {
this.innerHTML += /* html */`
<h2>Menu</h2>
<ul style="list-style: none; padding: 0;">
<li><a href="/join">Join</a></li>
<li><a href="/locations">Locations</a></li>
<li><a href="/donate">Donate</a></li>
<li><a href="/events">Events</a></li>
<li><a href="/login">Login</a></li>
</ul>
<img src="_/icons/x.svg" style="margin-top: 70vw; width: 10vw; height: 10vw" onclick="">
`
this.querySelector("img").addEventListener("click", () => {
const sidebar = document.querySelector("side-bar");
sidebar.style.right = "-100vw"
})
}
}
customElements.define("side-bar", SideBar)

View File

@@ -0,0 +1,138 @@
class SignupForm extends Shadow {
errorMessage = "Error signing up. Please try again later or email info@hyperia.so if the problem persists."
successMessage = "Success! You may now log in."
inputStyles(el) {
return el
.border("1px solid var(--accent)")
.color("var(--accent)")
}
render() {
ZStack(() => {
form(() => {
VStack(() => {
p()
.attr({id: "signupMessage"})
.display("none")
.padding(1, em)
.color("var(--main)")
.background("var(--accent)")
HStack(() => {
VStack(() => {
input("First Name*")
.attr({name: "firstName", type: "name", required: "true"})
.styles(this.inputStyles)
input("Last Name*")
.attr({name: "lastName", type: "name", required: "true"})
.styles(this.inputStyles)
input("Email*")
.attr({name: "email", type: "email", required: "true"})
.styles(this.inputStyles)
input("Password*")
.attr({name: "password", type: "password", required: "true"})
.styles(this.inputStyles)
input("Confirm Password*")
.attr({name: "password", type: "password", required: "true"})
.styles(this.inputStyles)
})
.width(50, "%")
.gap(1, em)
VStack(() => {
input("Street Address*")
.attr({ name: "address1", type: "text", autocomplete: "address-line1", required: "true" })
.styles(this.inputStyles)
input("Apt, Suite, Unit (optional)")
.attr({ name: "address2", type: "text", autocomplete: "address-line2" })
.styles(this.inputStyles)
input("City*")
.attr({ name: "city", type: "text", autocomplete: "address-level2", required: "true" })
.styles(this.inputStyles)
input("State*")
.attr({ name: "state", type: "text", autocomplete: "address-level1", required: "true" })
.styles(this.inputStyles)
input("ZIP Code*")
.attr({ name: "zip", type: "text", autocomplete: "postal-code", required: "true" })
.styles(this.inputStyles)
input("Country*")
.attr({ name: "country", type: "text", autocomplete: "country-name", required: "true" })
.styles(this.inputStyles)
})
.width(50, "%")
.gap(1, em)
})
.gap(2, em)
button("Submit")
})
.gap(2, em)
})
.color("var(--accent)")
.onSubmit(async (e) => {
e.preventDefault()
console.log("submitting")
$("#signupMessage").style.display = "none"
const formData = new FormData(this.$("form"));
const data = Object.fromEntries(formData.entries());
let newMember = {
"email": data.email,
"firstName": data.firstName,
"lastName": data.lastName,
"password": data.password
}
let address = {
"address1": data.address1,
"address2": data.address2,
"zip": data.zip,
"state": data.state,
"city": data.city
}
newMember.address = address
try {
const response = await fetch(window.location.pathname + window.location.search, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(newMember)
});
if (!response.ok) {
$("#signupMessage").style.display = "block"
$("#signupMessage").innerText = this.errorMessage
throw new Error(`HTTP error! status: ${response.status}`);
} else {
$("#signupMessage").style.display = "block"
$("#signupMessage").innerText = this.successMessage
}
} catch (err) {
console.error("Fetch error:", err);
}
})
.x(50, vw).y(53, vh)
.width(60, vw)
.center()
})
}
}
register(SignupForm)

21
ui/public/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hyperia</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/_/icons/logo.svg">
<link rel="stylesheet" href="/_/code/shared.css">
<style>
body {
font-size: 16px;
background-image: url("/_/images/fabric.webp");
background-size: 33vw auto; /* width height of each tile */
}
</style>
<script src="/_/code/quill.js"></script>
<script type="module" src="75820185/index.js"></script>
</head>
<body>
</body>
</html>

2
ui/public/index.js Normal file
View File

@@ -0,0 +1,2 @@
import "./pages/Home.js"
Home()

325
ui/public/pages/Events.js Normal file
View File

@@ -0,0 +1,325 @@
class Events extends Shadow {
events = [
{
date: `January 23, 2025`,
title: `Hyperia Winter Ball`,
description: `Join us in Austin, Texas for a dance. Live music and drinks will be included. <br>Admission for men is $50, women are free. Open to the public.`,
location: `Austin, TX`
}
]
render() {
ZStack(() => {
VStack(() => {
h1("HYPERIA")
.marginBottom(0, em)
p("Public Events")
.fontSize(1.2, em)
.marginBottom(2, em)
const Stack = window.isMobile() ? VStack : HStack
Stack(() => {
VStack(() => {
p(`January 23, 2025`)
p(`Hyperia Winter Ball`)
.fontSize(1.2, em)
p(`Austin, TX`)
})
p(`Join us in Austin, Texas for a great dance, with free drinks and live music. <br><br>Admission: $35 for men, women are free.`)
.marginRight(4, em)
HStack(() => {
img("/_/icons/creditcards/visa.svg")
img("/_/icons/creditcards/mastercard.svg")
img("/_/icons/creditcards/discover.svg")
img("/_/icons/creditcards/amex.svg")
})
.alignSelf("flex-start")
.height(2, em)
.maxWidth(40, vw)
button("Buy Ticket")
.color("var(--darkbrown")
.border("1px solid #ab2f007d")
.background('var(--green)')
.marginLeft("auto")
.onClick(async function() {
this.innerText = "Loading..."
const res = await fetch("/create-checkout-session", { method: "POST" });
const data = await res.json();
window.location = data.url;
})
})
.gap(3, em)
.color("var(--darkbrown)")
.background(`var(--accent)`)
.padding(1, em)
.borderRadius(12, px)
.border("2px solid #ab2f007d")
})
.marginLeft(window.isMobile() ? 0 : 15, vmax)
.marginRight(window.isMobile() ? 0 : 15, vmax)
.marginTop(10, vmax)
HStack(() => {
p("Privacy Policy")
.onHover(function (hovering) {
if(hovering) {
this.style.color = "var(--darkbrown)"
} else {
this.style.color = ""
}
})
.onClick(() => {
this.$("#policyWindow").style.display = "flex"
})
p("Refund and Return Policy")
.onHover(function (hovering) {
if(hovering) {
this.style.color = "var(--darkbrown)"
} else {
this.style.color = ""
}
})
.onClick(() => {
this.$("#refundWindow").style.display = "flex"
})
p("Contact Us")
.onHover(function (hovering) {
if(hovering) {
this.style.color = "var(--darkbrown)"
} else {
this.style.color = ""
}
})
.onClick(() => {
this.$("#contactWindow").style.display = "flex"
})
})
.x(50, vw).yBottom(0, vh)
.center()
.gap(2, em)
.opacity(0.5)
.cursor("default")
})
VStack(() => {
p("Privacy Policy")
.fontSize(2, em)
.fontWeight(600)
.marginBottom(1, em)
p("We value your privacy. This Privacy Policy explains how we collect, use, store, and protect your information when you use our website or services.")
p("1. Information We Collect")
.fontWeight(600)
.marginTop(1, em)
p("• Personal information you provide, such as your name, email address, or other contact details.")
p("• Automatically collected data, including IP address, browser type, device information, and usage statistics.")
p("• Cookies or similar tracking technologies that help us improve the user experience.")
p("2. How We Use Your Information")
.fontWeight(600)
.marginTop(1, em)
p("• To operate and improve our website and services.")
p("• To communicate with you about updates, support requests, or account-related matters.")
p("• To maintain security, prevent fraud, and ensure proper functionality.")
p("3. How We Share Information")
.fontWeight(600)
.marginTop(1, em)
p("We do not sell your personal information. We may share data only with trusted service providers who help us operate the platform, or when required by law.")
p("4. Data Storage & Security")
.fontWeight(600)
.marginTop(1, em)
p("We use reasonable technical and administrative safeguards to protect your information. However, no system is completely secure, and we cannot guarantee absolute protection.")
p("5. Cookies")
.fontWeight(600)
.marginTop(1, em)
p("Our site may use cookies to remember preferences, analyze traffic, and enhance usability. You can disable cookies in your browser settings, but some features may stop working.")
p("6. Your Rights")
.fontWeight(600)
.marginTop(1, em)
p("Depending on your location, you may have rights to access, update, delete, or request a copy of your personal data. Contact us if you wish to exercise these rights.")
p("7. Third-Party Links")
.fontWeight(600)
.marginTop(1, em)
p("Our website may contain links to third-party sites. We are not responsible for their content or privacy practices.")
p("8. Changes to This Policy")
.fontWeight(600)
.marginTop(1, em)
p("We may update this Privacy Policy from time to time. Updated versions will be posted on this page with the effective date.")
p("9. Contact Us")
.fontWeight(600)
.marginTop(1, em)
p("If you have any questions about this Privacy Policy, feel free to contact us at info@hyperia.so.")
p("x")
.onClick(function (done) {
if(done) {
this.parentElement.style.display = "none"
}
})
.color("var(--red)")
.xRight(1, em).y(1, em)
.fontSize(2, em)
.cursor("pointer")
})
.x(50, vw).y(50, vh)
.width(70, vw).height(70, vh)
.center()
.backgroundColor("var(--accent)")
.display("none")
.overflow("scroll")
.padding(1, em)
.border("3px solid black")
.color("var(--darkbrown)")
.attr({ id: "policyWindow" })
VStack(() => {
p("Refund & Return Policy")
.fontSize(2, em)
.fontWeight(600)
.marginBottom(1, em)
p("1. Eligibility for Refunds")
.fontWeight(600)
.marginTop(1, em)
p("• Refund requests may be considered when submitted within 14 days of purchase.")
p("• To qualify, you must provide proof of purchase and a valid reason for the request.")
p("• Certain digital products or services may be non-refundable once accessed or downloaded.")
p("2. Non-Refundable Items")
.fontWeight(600)
.marginTop(1, em)
p("• Products or services that have already been delivered, downloaded, or accessed in full.")
p("• Custom work, personalized items, or one-time service fees.")
p("• Any promotional or discounted items, unless required by law.")
p("3. Returns (If Applicable)")
.fontWeight(600)
.marginTop(1, em)
p("• Physical items must be returned in their original condition.")
p("• You are responsible for return shipping costs unless the item was defective or incorrect.")
p("• Items damaged through misuse or neglect cannot be returned.")
p("4. Processing Refunds")
.fontWeight(600)
.marginTop(1, em)
p("• Approved refunds are issued to the original payment method.")
p("• Processing times may vary depending on your bank or payment provider.")
p("• We will notify you once your refund has been initiated.")
p("5. Cancellations")
.fontWeight(600)
.marginTop(1, em)
p("• Orders or subscriptions may be cancelled before fulfillment or renewal.")
p("• If Hyperia declare a cancellation of any product or event, a refund will be issued to all parties.")
p("6. Contact for Refund Requests")
.fontWeight(600)
.marginTop(1, em)
p("If you need to request a refund, return an item, or cancel an order, please contact us at info@hyperia.so. Include your order number and relevant details so we can assist you promptly.")
p("7. Policy Updates")
.fontWeight(600)
.marginTop(1, em)
p("We may update this Refund & Return Policy from time to time. Any changes will be posted on this page with the effective date.")
p("x")
.onClick(function (done) {
if(done) {
this.parentElement.style.display = "none"
}
})
.color("var(--red)")
.xRight(1, em).y(1, em)
.fontSize(2, em)
.cursor("pointer")
})
.x(50, vw).y(50, vh)
.width(70, vw).height(70, vh)
.center()
.backgroundColor("var(--accent)")
.display("none")
.overflow("scroll")
.padding(1, em)
.border("3px solid black")
.color("var(--darkbrown)")
.attr({ id: "refundWindow" })
VStack(() => {
p("Contact Us")
.fontSize(2, em)
.fontWeight(600)
.marginBottom(1, em)
p("Email: info@hyperia.so")
p("Phone: 813-373-9100")
p("Address: 2014 E 9th St, Unit A, Austin TX")
p("x")
.onClick(function (done) {
if(done) {
this.parentElement.style.display = "none"
}
})
.color("var(--red)")
.xRight(1, em).y(1, em)
.fontSize(2, em)
.cursor("pointer")
})
.gap(2, em)
.x(50, vw).y(50, vh)
.width(50, vw).height(50, vh)
.center()
.backgroundColor("var(--accent)")
.display("none")
.overflow("scroll")
.padding(1, em)
.border("3px solid black")
.color("var(--darkbrown)")
.attr({ id: "contactWindow" })
}
}
register(Events)

81
ui/public/pages/Home.js Normal file
View File

@@ -0,0 +1,81 @@
import "../components/NavBar.js"
import "../components/SignupForm.js"
import "./Why.js"
import "./Events.js"
import "./Join.js"
import "./SignIn.js"
import "./Success.js"
class Home extends Shadow {
render() {
ZStack(() => {
NavBar()
img("/_/icons/logo.svg", "2.5em")
.onClick((done) => {
if(!done) return
window.navigateTo("/")
})
.position("absolute")
.left(50, vw).top(4, em)
.center()
.transform(`translate(${window.isMobile() ? "-50%" : "-2em"}, -50%)`)
switch(window.location.pathname) {
case "/":
img("/_/images/knight.png", "29vmax")
.position("absolute")
.left(50, vw).top(isMobile() ? 50 : 53, vh)
.center()
p("H &nbsp; Y &nbsp; P &nbsp; E &nbsp; R &nbsp; I &nbsp; A &nbsp;")
.x(50, vw).y(isMobile() ? 50 : 53, vh)
.textAlign("center")
.center()
.color("var(--gold)")
.fontSize(isMobile() ? 6 : 5, vw)
.maxWidth(isMobile() ? 0.8 : 100, em)
if(!isMobile()) {
let text = "A Classical Christian Network"
p(isMobile() ? text : text.toUpperCase())
.x(50, vw).yBottom(isMobile() ? 1 : 3, vh)
.center()
.letterSpacing(0.3, em)
.width(isMobile() ? 80 : 100, vw)
.fontSize(isMobile() ? 0.8 : 1, em)
.textAlign("center")
}
break;
case "/why":
Why()
break;
case "/events":
Events()
break;
case "/join":
Join()
break;
case "/success":
Success()
break;
default:
if(window.location.pathname.startsWith("/signup")) {
SignupForm()
} else if(window.location.pathname.startsWith("/signin")) {
SignIn()
}
}
})
.onNavigate(() => {
this.rerender()
})
}
}
register(Home)

29
ui/public/pages/Join.js Normal file
View File

@@ -0,0 +1,29 @@
class Join extends Shadow {
render() {
VStack(() => {
p("Membership is invitation-only. Wait to meet one of us, or come to one of our events!")
// p("Membership is invitation-only. But sign up for our newsletter to hear about more events!")
// HStack(() => {
// input("Email", "40vmin")
// .attr({name: "email", type: "email"})
// button("Sign Up")
// .width(15, vmin)
// })
// .gap(1, em)
// .marginTop(1, em)
})
.alignItems("center")
.maxWidth(90, vw)
.x(50, vw).y(50, vh)
.center()
}
}
register(Join)

36
ui/public/pages/SIgnIn.js Normal file
View File

@@ -0,0 +1,36 @@
class SignIn extends Shadow {
inputStyles(el) {
return el
.color("var(--accent)")
.border("1px solid var(--accent)")
}
render() {
ZStack(() => {
if(window.location.search.includes("new")) {
p("Welcome to Hyperia! You may now log in.")
.x(50, vw).y(40, vh)
.center()
}
form(() => {
input("Email")
.attr({name: "email", type: "email"})
.margin(1, em)
.styles(this.inputStyles)
input("Password")
.attr({name: "password", type: "password"})
.margin(1, em)
.styles(this.inputStyles)
button("Submit")
.margin(1, em)
})
.attr({action: "/login", method: "POST"})
.x(50, vw).y(50, vh)
.center()
})
}
}
register(SignIn)

View File

@@ -0,0 +1,9 @@
class Success extends Shadow {
render() {
p("Thanks for your purchase! You will receive a confirmation email shortly. <br><br> <b>Keep that email; it will be checked at the door.</b>")
.x(50, vw).y(50, vh)
.center()
}
}
register(Success)

21
ui/public/pages/Why.js Normal file
View File

@@ -0,0 +1,21 @@
class Why extends Shadow {
render() {
p(`I grew up going to Classical Christian schools all my life. Little did I know, this was a very unique experience - we got to learn all about our history, and everyone had a shared moral understanding.
<br><br>Only when I went out into the world did I realize that most Americans have no idea what this is like. They have never been a part of a shared culture, and the only value they know is multiculturalism.
<br><br>As adults, that is the world the we are all expected to live in.
<br><br>Classical Christian schools are great, but what if I want to live a Classical Christian life?
<br><br>That is what Hyperia is for. It is a Classical Christian space for adults.
<br><br> -- Sam Russell, Founder
`)
.marginTop(window.isMobile() ? 20 : 30, vh)
.marginHorizontal(window.isMobile() ? 10 : 20, vw)
.marginBottom(20, vh)
}
}
register(Why)

68
ui/public/paintingTags.js Normal file
View File

@@ -0,0 +1,68 @@
// Create the hover display element
const hoverBox = document.createElement('div');
hoverBox.style.id = "hoverBox"
hoverBox.style.position = 'fixed';
hoverBox.style.padding = '8px 12px';
hoverBox.style.backgroundColor = 'var(--green)';
hoverBox.style.border = '1px solid var(--tan)';
hoverBox.style.color = 'var(--tan)';
hoverBox.style.opacity = '80%';
hoverBox.style.pointerEvents = 'none';
hoverBox.style.zIndex = '9999';
hoverBox.style.fontFamily = 'sans-serif';
hoverBox.style.fontSize = '14px';
hoverBox.style.display = 'none';
document.body.appendChild(hoverBox);
let currentTarget = null;
function capitalizeWords(str) {
return str
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function onMouseOver(e) {
const target = e.target;
let paintingName; let artistName;
if(target.id === "back") {
paintingName = "The Garden Terrace"
artistName = "Caspar David Friedrich"
} else if (target.tagName.toLowerCase() === 'img' && target.classList.contains('interactive')) {
const match = target.src.match(/([^\/]+)\.([a-z]{3,4})(\?.*)?$/i); // extract filename
if (!match) return;
const filename = match[1];
const parts = filename.split('_');
if (parts.length !== 2) return;
paintingName = capitalizeWords(parts[0]);
artistName = capitalizeWords(parts[1]);
} else {
return
}
hoverBox.innerHTML = `<strong>${paintingName}</strong><br><span style="font-size: 12px;">${artistName}</span>`;
hoverBox.style.display = 'block';
currentTarget = target;
hoverBox.style.left = `${e.clientX + 15}px`;
hoverBox.style.top = `${e.clientY + 15}px`;
}
function onMouseOut(e) {
if (e.target === currentTarget) {
hoverBox.style.display = 'none';
currentTarget = null;
}
}
function onMouseMove(e) {
if (hoverBox.style.display === 'block') {
hoverBox.style.left = `${e.clientX + 15}px`;
hoverBox.style.top = `${e.clientY + 15}px`;
}
}
document.addEventListener('mouseover', onMouseOver);
document.addEventListener('mouseout', onMouseOut);
document.addEventListener('mousemove', onMouseMove);

56
ui/public/scrollEffect.js Normal file
View File

@@ -0,0 +1,56 @@
let treeOriginalTop = null;
let currentVelocity = 0;
let isAnimating = false;
window.addEventListener('wheel', (e) => {
if(window.innerWidth < 600) {
return;
}
// Add scroll delta to the velocity
currentVelocity += e.deltaY;
// Start animation loop if not running
if (!isAnimating) {
isAnimating = true;
requestAnimationFrame(animateScroll);
}
}, { passive: false });
function animateScroll() {
const tree = document.getElementById("tree");
if (!treeOriginalTop) {
treeOriginalTop = parseInt(getComputedStyle(tree).top);
}
const treeHeightPX = 0.83 * window.innerHeight;
let treeTopPX = parseInt(getComputedStyle(tree).top);
// Limit per-frame speed (but NOT total speed)
let multiplier = window.innerHeight / 2000;
let delta = Math.max(-100 * multiplier, Math.min(100 * multiplier, currentVelocity));
// Apply the scroll
let newTop = treeTopPX - delta;
// Clamp top/bottom bounds
const maxTop = treeOriginalTop;
const minTop = treeOriginalTop - treeHeightPX;
if (newTop > maxTop) newTop = maxTop;
if (newTop < minTop) newTop = minTop;
tree.style.top = `${newTop}px`;
// Slowly reduce velocity
currentVelocity *= 0.85;
// If velocity is small, stop
if (Math.abs(currentVelocity) > 0.5) {
requestAnimationFrame(animateScroll);
} else {
isAnimating = false;
currentVelocity = 0;
}
}

5
ui/readme.md Normal file
View File

@@ -0,0 +1,5 @@
# Installs
Stripe CLI
stripe listen --forward-to localhost:3003/webhook