commit c92742e8a1eeb854efb6c5b0286bf1c9b7ca0c6e Author: metacryst Date: Sat Dec 20 15:26:48 2025 -0600 initting diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c46f42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +package-lock.json +node_modules +.env + +content +db/db.json +qrCodes/qr_codes \ No newline at end of file diff --git a/_test/OrderedObject.test.js b/_test/OrderedObject.test.js new file mode 100644 index 0000000..c4e6033 --- /dev/null +++ b/_test/OrderedObject.test.js @@ -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!" + } + + } + +) \ No newline at end of file diff --git a/_test/test.js b/_test/test.js new file mode 100644 index 0000000..9f7f5cf --- /dev/null +++ b/_test/test.js @@ -0,0 +1,57 @@ +let scriptToPaste = ` + +`; +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 new Promise(res => setTimeout(res, ms)); + +window.__defineGetter__("test", test); \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..13f8fc6 --- /dev/null +++ b/main.js @@ -0,0 +1,2 @@ +import Server from "./server/index.js" +let server = new Server() \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..eefd4ff --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server/_/quilldb.js b/server/_/quilldb.js new file mode 100644 index 0000000..6ec555d --- /dev/null +++ b/server/_/quilldb.js @@ -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 } + } +} \ No newline at end of file diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..7f7c0d9 --- /dev/null +++ b/server/auth.js @@ -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("/") + } +} \ No newline at end of file diff --git a/server/db/db.js b/server/db/db.js new file mode 100644 index 0000000..999e2f0 --- /dev/null +++ b/server/db/db.js @@ -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"); + } +} \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..48e0580 --- /dev/null +++ b/server/index.js @@ -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); + } +} \ No newline at end of file diff --git a/server/util.js b/server/util.js new file mode 100644 index 0000000..cbf3cb4 --- /dev/null +++ b/server/util.js @@ -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}`; +} \ No newline at end of file diff --git a/server/ws/handlers/ForumHandler.js b/server/ws/handlers/ForumHandler.js new file mode 100644 index 0000000..b59faf9 --- /dev/null +++ b/server/ws/handlers/ForumHandler.js @@ -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) + } + + } +} \ No newline at end of file diff --git a/server/ws/handlers/MessagesHandler.js b/server/ws/handlers/MessagesHandler.js new file mode 100644 index 0000000..afc10c1 --- /dev/null +++ b/server/ws/handlers/MessagesHandler.js @@ -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) + } + + } +} \ No newline at end of file diff --git a/server/ws/ws.js b/server/ws/ws.js new file mode 100644 index 0000000..321847e --- /dev/null +++ b/server/ws/ws.js @@ -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); + } + }); + } +} diff --git a/ui/_/code/components/EmailJoinForm.js b/ui/_/code/components/EmailJoinForm.js new file mode 100644 index 0000000..58227f7 --- /dev/null +++ b/ui/_/code/components/EmailJoinForm.js @@ -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); \ No newline at end of file diff --git a/ui/_/code/quill.js b/ui/_/code/quill.js new file mode 100644 index 0000000..fcebb29 --- /dev/null +++ b/ui/_/code/quill.js @@ -0,0 +1,1047 @@ +/* + Sam Russell + Captured Sun + 11.25.25.1 - Added minHeight and minWidth to be counted as numerical styles + 11.25.25 - Added onChange for check boxes, added setQuery / onQueryChanged for easy filtering + 11.24.25 - Fixing onClick because it was reversed, adding event to onHover params + 11.23.25 - Added onSubmit() event for form submission, added marginHorizontal() and marginVertical() + 11.20.25 - Added "pct" style unit, added alignVertical and alignHorizontal for flex boxes + 11.19.25 - Allowing for "auto" values in otherwise numeric styles, adding vmin and vmax units + 11.17.25.3 - Adding styles() and fixing dynamic function from earlier + 11.17.25.2 - Fixing onNavigate() and onAppear() + 11.17.25 - Added dynamic function to have units in style func parameters. + 11.14.25 - Added onTouch, onTap. Changed style setters to work with Safari. Added center() funcs. + 11.13.25 - changed onFocus() to be a boolean event, added onInput() + 11.9.25 - changed p(innerText) to p(innerHTML), adjusted onNavigate to work for multiple elements and with correct "this" scope + 11.7.25 - changed registerShadow() to register(), changed onClick() to be like onHover() + 11.6.25 - adding default value for "button()" "children" parameter + 10.29.25 - adding "gap()" and "label()" functions +*/ + +/* $ NAVIGATION */ +let oldPushState = history.pushState; +history.pushState = function pushState() { + let ret = oldPushState.apply(this, arguments); + window.dispatchEvent(new Event('pushstate')); + window.dispatchEvent(new Event('navigate')); + return ret; +}; + +window.addEventListener('popstate', () => { + window.dispatchEvent(new Event('navigate')); +}); + +window.setQuery = function(key, value) { + const url = new URL(window.location.href); + const params = url.searchParams; + + if (value === null || value === undefined) { + params.delete(key); + } else { + params.set(key, value); + } + + const newUrl = url.toString(); + history.replaceState(null, "", newUrl); + window.dispatchEvent(new Event('query-changed')); + + return newUrl; +}; + +window.navigateTo = function(url) { + window.dispatchEvent(new Event('navigate')); + window.history.pushState({}, '', url); +} + +/* $ SELECTOR */ + +HTMLElement.prototype.$ = function(selector) { + return window.$(selector, this) +} +DocumentFragment.prototype.$ = function(selector) { + return window.$(selector, this) +} +window.$ = function(selector, el = document) { + return el.querySelector(selector) +} + +window.$$ = function(selector, el = document) { + return Array.from(el.querySelectorAll(selector)) +} + +/* CONSOLE */ + +console.red = function(message) { + this.log(`%c${message}`, "color: rgb(254, 79, 42);"); +}; + +console.green = function(message) { + this.log(`%c${message}`, "color: rgb(79, 254, 42);"); + +} + +/* GET CSS VARIABLES FOR DARK OR LIGHT MODE */ +window.darkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; +document.documentElement.classList.add(darkMode ? 'dark' : 'light'); + +window.getColor = function(name) { + const rootStyles = getComputedStyle(document.documentElement); + const color = rootStyles.getPropertyValue(`--${name}`).trim(); + if(!color) { + throw new Error("Color not found") + } + return color +} + +/* MOBILE */ + +window.isMobile = function isMobile() { + return /Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i.test(navigator.userAgent); +} + +window.css = function css(cssString) { + let container = document.querySelector("style#pageStyle"); + if(!container) { + container = document.createElement('style'); + container.id = "pageStyle"; + document.head.appendChild(container); + } + + let primarySelector = cssString.substring(0, cssString.indexOf("{")).trim(); + primarySelector = primarySelector.replace(/\*/g, "all"); + primarySelector = primarySelector.replace(/#/g, "id-"); + primarySelector = primarySelector.replace(/,/g, ""); + let stylesheet = container.querySelector(`:scope > style[id='${primarySelector}']`) + if(!stylesheet) { + stylesheet = document.createElement('style'); + stylesheet.id = primarySelector; + stylesheet.appendChild(document.createTextNode(cssString)); + container.appendChild(stylesheet); + } else { + stylesheet.innerText = cssString + } +} + +window.html = function html(elementString) { + let parser = new DOMParser(); + let doc = parser.parseFromString(elementString, 'text/html'); + return doc.body.firstChild; +} + +window.util = {} +window.util.observeClassChange = (el, callback) => { + if (!el || !(el instanceof Element)) { + throw new Error("observeClassChange requires a valid DOM element."); + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "attributes" && mutation.attributeName === "class") { + callback(el.classList); + } + } + }); + + observer.observe(el, { + attributes: true, + attributeFilter: ["class"] + }); + + return observer; // Optional: return it so you can disconnect later +} + +/* PAGE SETUP */ + +Object.defineProperty(Array.prototype, 'last', { + get() { + return this[this.length - 1]; + }, + enumerable: false, +}); + +/* QUILL */ + +window.quill = { + rendering: [], + + render: (el) => { + if(el instanceof Shadow) { + let parent = quill.rendering[quill.rendering.length-1] + if(!parent) { + parent = document.body + } + parent.appendChild(el) + } else { + if(!el.render) {el.render = () => {}} + let parent = quill.rendering[quill.rendering.length-1] + if(!parent) throw new Error("Quill: no parent for element") + parent.appendChild(el) + } + + quill.rendering.push(el) + el.render() + quill.rendering.pop(el) + }, + + rerender: (el) => { + Array.from(el.attributes).forEach(attr => el.removeAttribute(attr.name)); + el.innerHTML = "" + el.removeAllListeners() + + quill.rendering.push(el) + el.render() + quill.rendering.pop() + }, + + loadPage: () => { + let URL = window.location.pathname + if(!window.routes[URL]) { + throw new Error("No URL for this route: ", URL) + } + + let pageClass = window[routes[URL]] + document.title = pageClass.title ?? document.title + document.body.innerHTML = "" + let page = new pageClass() + quill.render(page) + }, + + isStack: (el) => { + return el.classList.contains("HStack") || el.classList.contains("ZStack") || el.classList.contains("VStack") + }, +} + +window.Shadow = class extends HTMLElement { + constructor() { + super() + } +} +window.register = (el, tagname) => { + if (typeof el.prototype.render !== 'function') { + throw new Error("Element must have a render: " + el.prototype.constructor.name) + } + if(!tagname) { + tagname = el.prototype.constructor.name.toLowerCase() + "-" + } + customElements.define(tagname, el) + if(el.css) { + css(el.css) + } + + window[el.prototype.constructor.name] = function (...params) { + let instance = new el(...params) + quill.render(instance) + return instance + } +} + +HTMLElement.prototype.rerender = function() { + quill.rerender(this) +} + +/* Styling */ + +window.pct = "%" +window.vmin = "vmin" +window.vmax = "vmax" +window.vh = "vh" +window.vw = "vw" +window.px = "px" +window.em = "em" +window.rem = "rem" +window.inches = "in" + +HTMLElement.prototype.addStyle = function(func) { + return func(this) +} + +window.css = function css(cssString) { + let container = document.querySelector("style#pageStyle"); + if(!container) { + container = document.createElement('style'); + container.id = "pageStyle"; + document.head.appendChild(container); + } + + let primarySelector = cssString.substring(0, cssString.indexOf("{")).trim(); + primarySelector = primarySelector.replace(/\*/g, "all"); + primarySelector = primarySelector.replace(/#/g, "id-"); + primarySelector = primarySelector.replace(/,/g, ""); + let stylesheet = container.querySelector(`:scope > style[id='${primarySelector}']`) + if(!stylesheet) { + stylesheet = document.createElement('style'); + stylesheet.id = primarySelector; + stylesheet.appendChild(document.createTextNode(cssString)); + container.appendChild(stylesheet); + } else { + stylesheet.innerText = cssString + } +} + +function extendHTMLElementWithStyleSetters() { + + function cssValueType(prop) { + const div = document.createElement("div"); + const style = div.style; + if (!(prop in style)) return "invalid"; + + switch(prop) { + + case "gap": + case "borderRadius": + case "width": + case "height": + case "maxWidth": + case "maxHeight": + case "minWidth": + case "minHeight": + + case "left": + case "top": + case "bottom": + case "right": + + case "padding": + case "paddingLeft": + case "paddingTop": + case "paddingBottom": + case "paddingRight": + + case "margin": + case "marginLeft": + case "marginTop": + case "marginBottom": + case "marginRight": + + case "textUnderlineOffset": + + return "unit-number" + + default: + + return "string" + } + + } + + let allStyleProps = ["accentColor", "additiveSymbols", "alignContent", "alignItems", "alignSelf", "alignmentBaseline", "all", "anchorName", "anchorScope", "animation", "animationComposition", "animationDelay", "animationDirection", "animationDuration", "animationFillMode", "animationIterationCount", "animationName", "animationPlayState", "animationRange", "animationRangeEnd", "animationRangeStart", "animationTimeline", "animationTimingFunction", "appRegion", "appearance", "ascentOverride", "aspectRatio", "backdropFilter", "backfaceVisibility", "background", "backgroundAttachment", "backgroundBlendMode", "backgroundClip", "backgroundColor", "backgroundImage", "backgroundOrigin", "backgroundPosition", "backgroundPositionX", "backgroundPositionY", "backgroundRepeat", "backgroundSize", "basePalette", "baselineShift", "baselineSource", "blockSize", "border", "borderBlock", "borderBlockColor", "borderBlockEnd", "borderBlockEndColor", "borderBlockEndStyle", "borderBlockEndWidth", "borderBlockStart", "borderBlockStartColor", "borderBlockStartStyle", "borderBlockStartWidth", "borderBlockStyle", "borderBlockWidth", "borderBottom", "borderBottomColor", "borderBottomLeftRadius", "borderBottomRightRadius", "borderBottomStyle", "borderBottomWidth", "borderCollapse", "borderColor", "borderEndEndRadius", "borderEndStartRadius", "borderImage", "borderImageOutset", "borderImageRepeat", "borderImageSlice", "borderImageSource", "borderImageWidth", "borderInline", "borderInlineColor", "borderInlineEnd", "borderInlineEndColor", "borderInlineEndStyle", "borderInlineEndWidth", "borderInlineStart", "borderInlineStartColor", "borderInlineStartStyle", "borderInlineStartWidth", "borderInlineStyle", "borderInlineWidth", "borderLeft", "borderLeftColor", "borderLeftStyle", "borderLeftWidth", "borderRadius", "borderRight", "borderRightColor", "borderRightStyle", "borderRightWidth", "borderSpacing", "borderStartEndRadius", "borderStartStartRadius", "borderStyle", "borderTop", "borderTopColor", "borderTopLeftRadius", "borderTopRightRadius", "borderTopStyle", "borderTopWidth", "borderWidth", "bottom", "boxDecorationBreak", "boxShadow", "boxSizing", "breakAfter", "breakBefore", "breakInside", "bufferedRendering", "captionSide", "caretAnimation", "caretColor", "clear", "clip", "clipPath", "clipRule", "color", "colorInterpolation", "colorInterpolationFilters", "colorRendering", "colorScheme", "columnCount", "columnFill", "columnGap", "columnRule", "columnRuleColor", "columnRuleStyle", "columnRuleWidth", "columnSpan", "columnWidth", "columns", "contain", "containIntrinsicBlockSize", "containIntrinsicHeight", "containIntrinsicInlineSize", "containIntrinsicSize", "containIntrinsicWidth", "container", "containerName", "containerType", "content", "contentVisibility", "cornerBlockEndShape", "cornerBlockStartShape", "cornerBottomLeftShape", "cornerBottomRightShape", "cornerBottomShape", "cornerEndEndShape", "cornerEndStartShape", "cornerInlineEndShape", "cornerInlineStartShape", "cornerLeftShape", "cornerRightShape", "cornerShape", "cornerStartEndShape", "cornerStartStartShape", "cornerTopLeftShape", "cornerTopRightShape", "cornerTopShape", "counterIncrement", "counterReset", "counterSet", "cursor", "cx", "cy", "d", "descentOverride", "direction", "display", "dominantBaseline", "dynamicRangeLimit", "emptyCells", "fallback", "fieldSizing", "fill", "fillOpacity", "fillRule", "filter", "flex", "flexBasis", "flexDirection", "flexFlow", "flexGrow", "flexShrink", "flexWrap", "float", "floodColor", "floodOpacity", "font", "fontDisplay", "fontFamily", "fontFeatureSettings", "fontKerning", "fontOpticalSizing", "fontPalette", "fontSize", "fontSizeAdjust", "fontStretch", "fontStyle", "fontSynthesis", "fontSynthesisSmallCaps", "fontSynthesisStyle", "fontSynthesisWeight", "fontVariant", "fontVariantAlternates", "fontVariantCaps", "fontVariantEastAsian", "fontVariantEmoji", "fontVariantLigatures", "fontVariantNumeric", "fontVariantPosition", "fontVariationSettings", "fontWeight", "forcedColorAdjust", "gap", "grid", "gridArea", "gridAutoColumns", "gridAutoFlow", "gridAutoRows", "gridColumn", "gridColumnEnd", "gridColumnGap", "gridColumnStart", "gridGap", "gridRow", "gridRowEnd", "gridRowGap", "gridRowStart", "gridTemplate", "gridTemplateAreas", "gridTemplateColumns", "gridTemplateRows", "height", "hyphenateCharacter", "hyphenateLimitChars", "hyphens", "imageOrientation", "imageRendering", "inherits", "initialLetter", "initialValue", "inlineSize", "inset", "insetBlock", "insetBlockEnd", "insetBlockStart", "insetInline", "insetInlineEnd", "insetInlineStart", "interactivity", "interpolateSize", "isolation", "justifyContent", "justifyItems", "justifySelf", "left", "letterSpacing", "lightingColor", "lineBreak", "lineGapOverride", "lineHeight", "listStyle", "listStyleImage", "listStylePosition", "listStyleType", "margin", "marginBlock", "marginBlockEnd", "marginBlockStart", "marginBottom", "marginInline", "marginInlineEnd", "marginInlineStart", "marginLeft", "marginRight", "marginTop", "marker", "markerEnd", "markerMid", "markerStart", "mask", "maskClip", "maskComposite", "maskImage", "maskMode", "maskOrigin", "maskPosition", "maskRepeat", "maskSize", "maskType", "mathDepth", "mathShift", "mathStyle", "maxBlockSize", "maxHeight", "maxInlineSize", "maxWidth", "minBlockSize", "minHeight", "minInlineSize", "minWidth", "mixBlendMode", "navigation", "negative", "objectFit", "objectPosition", "objectViewBox", "offset", "offsetAnchor", "offsetDistance", "offsetPath", "offsetPosition", "offsetRotate", "opacity", "order", "orphans", "outline", "outlineColor", "outlineOffset", "outlineStyle", "outlineWidth", "overflow", "overflowAnchor", "overflowBlock", "overflowClipMargin", "overflowInline", "overflowWrap", "overflowX", "overflowY", "overlay", "overrideColors", "overscrollBehavior", "overscrollBehaviorBlock", "overscrollBehaviorInline", "overscrollBehaviorX", "overscrollBehaviorY", "pad", "padding", "paddingBlock", "paddingBlockEnd", "paddingBlockStart", "paddingBottom", "paddingInline", "paddingInlineEnd", "paddingInlineStart", "paddingLeft", "paddingRight", "paddingTop", "page", "pageBreakAfter", "pageBreakBefore", "pageBreakInside", "pageOrientation", "paintOrder", "perspective", "perspectiveOrigin", "placeContent", "placeItems", "placeSelf", "pointerEvents", "position", "positionAnchor", "positionArea", "positionTry", "positionTryFallbacks", "positionTryOrder", "positionVisibility", "prefix", "printColorAdjust", "quotes", "r", "range", "readingFlow", "readingOrder", "resize", "result", "right", "rotate", "rowGap", "rubyAlign", "rubyPosition", "rx", "ry", "scale", "scrollBehavior", "scrollInitialTarget", "scrollMargin", "scrollMarginBlock", "scrollMarginBlockEnd", "scrollMarginBlockStart", "scrollMarginBottom", "scrollMarginInline", "scrollMarginInlineEnd", "scrollMarginInlineStart", "scrollMarginLeft", "scrollMarginRight", "scrollMarginTop", "scrollMarkerGroup", "scrollPadding", "scrollPaddingBlock", "scrollPaddingBlockEnd", "scrollPaddingBlockStart", "scrollPaddingBottom", "scrollPaddingInline", "scrollPaddingInlineEnd", "scrollPaddingInlineStart", "scrollPaddingLeft", "scrollPaddingRight", "scrollPaddingTop", "scrollSnapAlign", "scrollSnapStop", "scrollSnapType", "scrollTargetGroup", "scrollTimeline", "scrollTimelineAxis", "scrollTimelineName", "scrollbarColor", "scrollbarGutter", "scrollbarWidth", "shapeImageThreshold", "shapeMargin", "shapeOutside", "shapeRendering", "size", "sizeAdjust", "speak", "speakAs", "src", "stopColor", "stopOpacity", "stroke", "strokeDasharray", "strokeDashoffset", "strokeLinecap", "strokeLinejoin", "strokeMiterlimit", "strokeOpacity", "strokeWidth", "suffix", "symbols", "syntax", "system", "tabSize", "tableLayout", "textAlign", "textAlignLast", "textAnchor", "textAutospace", "textBox", "textBoxEdge", "textBoxTrim", "textCombineUpright", "textDecoration", "textDecorationColor", "textDecorationLine", "textDecorationSkipInk", "textDecorationStyle", "textDecorationThickness", "textEmphasis", "textEmphasisColor", "textEmphasisPosition", "textEmphasisStyle", "textIndent", "textOrientation", "textOverflow", "textRendering", "textShadow", "textSizeAdjust", "textSpacingTrim", "textTransform", "textUnderlineOffset", "textUnderlinePosition", "textWrap", "textWrapMode", "textWrapStyle", "timelineScope", "top", "touchAction", "transform", "transformBox", "transformOrigin", "transformStyle", "transition", "transitionBehavior", "transitionDelay", "transitionDuration", "transitionProperty", "transitionTimingFunction", "translate", "types", "unicodeBidi", "unicodeRange", "userSelect", "vectorEffect", "verticalAlign", "viewTimeline", "viewTimelineAxis", "viewTimelineInset", "viewTimelineName", "viewTransitionClass", "viewTransitionGroup", "viewTransitionName", "visibility", "webkitAlignContent", "webkitAlignItems", "webkitAlignSelf", "webkitAnimation", "webkitAnimationDelay", "webkitAnimationDirection", "webkitAnimationDuration", "webkitAnimationFillMode", "webkitAnimationIterationCount", "webkitAnimationName", "webkitAnimationPlayState", "webkitAnimationTimingFunction", "webkitAppRegion", "webkitAppearance", "webkitBackfaceVisibility", "webkitBackgroundClip", "webkitBackgroundOrigin", "webkitBackgroundSize", "webkitBorderAfter", "webkitBorderAfterColor", "webkitBorderAfterStyle", "webkitBorderAfterWidth", "webkitBorderBefore", "webkitBorderBeforeColor", "webkitBorderBeforeStyle", "webkitBorderBeforeWidth", "webkitBorderBottomLeftRadius", "webkitBorderBottomRightRadius", "webkitBorderEnd", "webkitBorderEndColor", "webkitBorderEndStyle", "webkitBorderEndWidth", "webkitBorderHorizontalSpacing", "webkitBorderImage", "webkitBorderRadius", "webkitBorderStart", "webkitBorderStartColor", "webkitBorderStartStyle", "webkitBorderStartWidth", "webkitBorderTopLeftRadius", "webkitBorderTopRightRadius", "webkitBorderVerticalSpacing", "webkitBoxAlign", "webkitBoxDecorationBreak", "webkitBoxDirection", "webkitBoxFlex", "webkitBoxOrdinalGroup", "webkitBoxOrient", "webkitBoxPack", "webkitBoxReflect", "webkitBoxShadow", "webkitBoxSizing", "webkitClipPath", "webkitColumnBreakAfter", "webkitColumnBreakBefore", "webkitColumnBreakInside", "webkitColumnCount", "webkitColumnGap", "webkitColumnRule", "webkitColumnRuleColor", "webkitColumnRuleStyle", "webkitColumnRuleWidth", "webkitColumnSpan", "webkitColumnWidth", "webkitColumns", "webkitFilter", "webkitFlex", "webkitFlexBasis", "webkitFlexDirection", "webkitFlexFlow", "webkitFlexGrow", "webkitFlexShrink", "webkitFlexWrap", "webkitFontFeatureSettings", "webkitFontSmoothing", "webkitHyphenateCharacter", "webkitJustifyContent", "webkitLineBreak", "webkitLineClamp", "webkitLocale", "webkitLogicalHeight", "webkitLogicalWidth", "webkitMarginAfter", "webkitMarginBefore", "webkitMarginEnd", "webkitMarginStart", "webkitMask", "webkitMaskBoxImage", "webkitMaskBoxImageOutset", "webkitMaskBoxImageRepeat", "webkitMaskBoxImageSlice", "webkitMaskBoxImageSource", "webkitMaskBoxImageWidth", "webkitMaskClip", "webkitMaskComposite", "webkitMaskImage", "webkitMaskOrigin", "webkitMaskPosition", "webkitMaskPositionX", "webkitMaskPositionY", "webkitMaskRepeat", "webkitMaskSize", "webkitMaxLogicalHeight", "webkitMaxLogicalWidth", "webkitMinLogicalHeight", "webkitMinLogicalWidth", "webkitOpacity", "webkitOrder", "webkitPaddingAfter", "webkitPaddingBefore", "webkitPaddingEnd", "webkitPaddingStart", "webkitPerspective", "webkitPerspectiveOrigin", "webkitPerspectiveOriginX", "webkitPerspectiveOriginY", "webkitPrintColorAdjust", "webkitRtlOrdering", "webkitRubyPosition", "webkitShapeImageThreshold", "webkitShapeMargin", "webkitShapeOutside", "webkitTapHighlightColor", "webkitTextCombine", "webkitTextDecorationsInEffect", "webkitTextEmphasis", "webkitTextEmphasisColor", "webkitTextEmphasisPosition", "webkitTextEmphasisStyle", "webkitTextFillColor", "webkitTextOrientation", "webkitTextSecurity", "webkitTextSizeAdjust", "webkitTextStroke", "webkitTextStrokeColor", "webkitTextStrokeWidth", "webkitTransform", "webkitTransformOrigin", "webkitTransformOriginX", "webkitTransformOriginY", "webkitTransformOriginZ", "webkitTransformStyle", "webkitTransition", "webkitTransitionDelay", "webkitTransitionDuration", "webkitTransitionProperty", "webkitTransitionTimingFunction", "webkitUserDrag", "webkitUserModify", "webkitUserSelect", "webkitWritingMode", "whiteSpace", "whiteSpaceCollapse", "widows", "width", "willChange", "wordBreak", "wordSpacing", "wordWrap", "writingMode", "x", "y", "zIndex", "zoom"] + + allStyleProps.forEach(prop => { + if (prop === "translate") return; + + const type = cssValueType(prop); + + switch (type) { + case "unit-number": + HTMLElement.prototype[prop] = function(value, unit = "px") { + if ((typeof value !== "number" || isNaN(value)) && value !== "auto") { + throw new Error(`Invalid value for ${prop}: ${value}. Expected a number.`); + } + if(value === "auto") { + this.style[prop] = value + return this + } + this.style[prop] = value + unit; + return this; + }; + break; + + case "string": + HTMLElement.prototype[prop] = function(value) { + this.style[prop] = value; + return this; + }; + break; + } + }); +} + +extendHTMLElementWithStyleSetters(); + +HTMLElement.prototype.styles = function(cb) { + cb.call(this, this) + return this +} + +HTMLElement.prototype.paddingVertical = function(value, unit = "px") { + if ((typeof value !== 'number' && value !== "auto") || Number.isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + this.style.paddingTop = value + unit + this.style.paddingBottom = value + unit + return this +} + +HTMLElement.prototype.paddingHorizontal = function(value, unit = "px") { + if ((typeof value !== 'number' && value !== "auto") || Number.isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + this.style.paddingRight = value + unit + this.style.paddingLeft = value + unit + return this +} + +HTMLElement.prototype.marginVertical = function(value, unit = "px") { + if ((typeof value !== 'number' && value !== "auto") || Number.isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + this.style.marginTop = value + unit + this.style.marginBottom = value + unit + return this +} + +HTMLElement.prototype.marginHorizontal = function(value, unit = "px") { + if ((typeof value !== 'number' && value !== "auto") || Number.isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + this.style.marginRight = value + unit + this.style.marginLeft = value + unit + return this +} + +HTMLElement.prototype.fontSize = function(value, unit = "px") { + if ((typeof value !== 'number' && value !== "auto") || Number.isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + + switch(value) { + case "6xl": + value = "3.75"; unit = "rem" + break; + + case "5xl": + value = "3"; unit = "rem" + break; + + case "4xl": + value = "2.25"; unit = "rem" + break; + + case "3xl": + value = "1.875"; unit = "rem" + break; + + case "2xl": + value = "1.5"; unit = "rem" + break; + + case "xl": + value = "1.25"; unit = "rem" + break; + + case "l": + value = "1.125"; unit = "rem" + break; + + case "s": + value = "0.875"; unit = "rem" + break; + + case "xs": + value = "0.75"; unit = "rem" + break; + + default: + break; + } + this.style.fontSize = value + unit + return this +} + +function checkPositionType(el) { + let computed = window.getComputedStyle(el).position + if(!(computed === "absolute" || computed === "fixed")) { + el.style.position = "absolute" + } +} + +HTMLElement.prototype.x = function(value, unit = "px") { + if (typeof value !== 'number' || isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + checkPositionType(this) + this.style.left = value + unit + return this +} + +HTMLElement.prototype.y = function(value, unit = "px") { + if (typeof value !== 'number' || isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + checkPositionType(this) + this.style.top = value + unit + return this +} + +HTMLElement.prototype.xRight = function(value, unit = "px") { + if (typeof value !== 'number' || isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + checkPositionType(this) + this.style.right = value + unit + return this +} + +HTMLElement.prototype.yBottom = function(value, unit = "px") { + if (typeof value !== 'number' || isNaN(value)) + throw new Error(`Invalid value: ${value}. Expected a number.`); + checkPositionType(this) + this.style.bottom = value + unit + return this +} + +HTMLElement.prototype.backgroundImage = function (...values) { + const formatted = values + .map(v => { + if(v.includes("/") && !v.includes("gradient")) { + v = "url(" + v + ")" + } + return String(v).trim(); + }) + .join(", "); + + this.style.backgroundImage = formatted; + return this; +}; + +HTMLElement.prototype.center = function () { + this.style.transform = "translate(-50%, -50%)" + return this; +}; + +HTMLElement.prototype.centerX = function () { + this.style.transform = "translateX(-50%)" + return this; +}; + +HTMLElement.prototype.centerY = function () { + this.style.transform = "translateY(-50%)" + return this; +}; + +HTMLElement.prototype.alignVertical = function (value) { + const direction = getComputedStyle(this).flexDirection; + if(!direction) { + throw new Error("alignVertical can be only be used on HStacks or VStacks!") + } + + if (direction === "column" || direction === "column-reverse") { + this.style.justifyContent = value; + } else { + this.style.alignItems = value; + } + return this +} + +HTMLElement.prototype.alignHorizontal = function (value) { + const direction = getComputedStyle(this).flexDirection; + if(!direction) { + throw new Error("alignHorizontal can be only be used on HStacks or VStacks!") + } + + if (direction === "column" || direction === "column-reverse") { + this.style.alignItems = value; + } else { + this.style.justifyContent = value; + } + return this +} + + +/* Elements */ + +quill.setChildren = function(el, innerContent) { + if(typeof innerContent === "string") { + el.innerText = innerContent + } else if(typeof innerContent === "function") { + el.render = innerContent + } else { + throw new Error("Children of unknown type") + } +} + +window.a = function a( href, inner=href ) { + if(!href) throw new Error("quill a: missing href argument. Function: a( href, inner=href )") + let link = document.createElement("a") + link.setAttribute('href', href); + quill.setChildren(link, inner) + quill.render(link) + return link +} + +window.img = function img(src, width="", height="") { + let image = document.createElement("img") + + if(!src || !(typeof src==="string")) { + throw new Error("img: missing first argument: src | String") + } else { + image.src = src + } + if(width && typeof width === "string") { + image.style.width = width + } else if(width) { + image.style.width = width + "px" + } + if(height && typeof height === "string") { + image.style.height = height + } else if(height) { + image.style.height = height + "px" + } + quill.render(image) + return image +} + +HTMLImageElement.prototype.backgroundColor = function(value) { + if (this.src.endsWith('.svg') || this.src.startsWith('data:image/svg+xml')) { + fetch(this.src).then(response => response.text()) + .then(svgText => { + const modifiedSvg = svgText.replace(/\bfill="[^"]*"/g, `fill="${value}"`); + const blob = new Blob([modifiedSvg], { type: 'image/svg+xml' }); + this.src = URL.createObjectURL(blob); + }).catch(error => { + console.error('Error updating SVG fill:', error); + }); + } else { + this.style.backgroundColor = value; + } + + return this; // Always returns the element itself + }; + +window.p = function p(innerHTML) { + let el = document.createElement("p") + if(typeof innerText === "function") { + el.render = innerHTML + } else { + el.innerHTML = innerHTML + } + el.style.margin = "0"; + quill.render(el) + return el +} + +window.h1 = function h1(innerText) { + let el = document.createElement("h1") + el.innerText = innerText + quill.render(el) + return el +} + +window.h2 = function h2(innerText) { + let el = document.createElement("h2") + el.innerText = innerText + quill.render(el) + return el +} + +window.h3 = function h3(innerText) { + let el = document.createElement("h3") + el.innerText = innerText + quill.render(el) + return el +} + +window.div = function (innerText) { + let el = document.createElement("div") + el.innerText = innerText ?? "" + quill.render(el) + return el +} + +window.span = function (innerText) { + let el = document.createElement("span") + el.innerText = innerText + quill.render(el) + return el +} + +window.button = function (children = "") { + let el = document.createElement("button") + quill.setChildren(el, children) + quill.render(el) + return el +} + +window.form = function(cb) { + let el = document.createElement("form") + el.render = cb + quill.render(el) + return el +} + +window.input = function(placeholder, width, height) { + let el = document.createElement("input") + el.placeholder = placeholder + el.style.width = width + el.style.height = height + quill.render(el) + return el +} + +window.label = function(text) { + let el = document.createElement("label") + el.innerText = text + quill.render(el) + return el +} + +window.textarea = function(placeholder) { + let el = document.createElement("textarea") + el.placeholder = placeholder + quill.render(el) + return el +} + + +/* STACKS */ + +window.VStack = function (cb = () => {}) { + let styles = ` + display: flex; + flex-direction: column; + ` + let nowRendering = quill.rendering[quill.rendering.length-1] + if (nowRendering.innerHTML.trim() === "" && !quill.isStack(nowRendering)) { + nowRendering.style.cssText += styles + nowRendering.classList.add("VStack") + cb() + return nowRendering + } + + let div = document.createElement("div") + div.classList.add("VStack") + div.style.cssText += styles + div.render = cb + quill.render(div) + return div +} + +window.HStack = function (cb = () => {}) { + let styles = ` + display: flex; + flex-direction: row; + `; + let nowRendering = quill.rendering[quill.rendering.length - 1]; + if (nowRendering.innerHTML.trim() === "" && !quill.isStack(nowRendering)) { + nowRendering.style.cssText += styles; + nowRendering.classList.add("HStack") + cb(); + return nowRendering; + } + + let div = document.createElement("div"); + div.classList.add("HStack"); + div.style.cssText += styles; + div.render = cb; + quill.render(div); + return div; +}; + +window.ZStack = function (cb = () => {}) { + let nowRendering = quill.rendering[quill.rendering.length - 1]; + if (nowRendering.innerHTML.trim() === "" && !quill.isStack(nowRendering)) { + nowRendering.classList.add("ZStack") + cb(); + return nowRendering; + } + + let div = document.createElement("div"); + div.classList.add("ZStack"); + div.render = cb; + quill.render(div); + return div; +}; + +/* SHAPES */ + +window.svgMethods = function(svg) { + svg.pulse = function (duration = 600) { + this.style.transition = `transform ${duration}ms ease-in-out` + this.style.transform = "scale(1.2)" + setTimeout(() => { + this.style.transform = "scale(1)" + }, duration / 2) + return this + } + + // Rotate (e.g. loading spinner) + svg.rotate = function (degrees = 360, duration = 1000) { + this.style.transition = `transform ${duration}ms linear` + this.style.transform = `rotate(${degrees}deg)` + return this + } + + // Change color + svg.fill = function (color) { + this.setAttribute("fill", color) + return this + } + + svg.height = function (height) { + this.setAttribute("height", height) + return this + } + + svg.width = function (width) { + this.setAttribute("width", width) + return this + } + + svg.stroke = function (width, color) { + this.setAttribute("stroke", color) + this.setAttribute("stroke-width", width) + return this + } + + // Toggle visibility + svg.toggle = function () { + this.style.display = this.style.display === "none" ? "" : "none" + return this + } +} + +window.Rectangle = function (width = "40px", height = "40px") { + const svgNS = "http://www.w3.org/2000/svg"; + const svgEl = document.createElementNS(svgNS, "svg"); + const rectEl = document.createElementNS(svgNS, "rect"); + + // SVG size + svgEl.setAttribute("width", width); + svgEl.setAttribute("height", height); + svgEl.setAttribute("viewBox", "0 0 100 100"); + svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + // Rectangle: full size, slightly rounded corners + rectEl.setAttribute("x", "15"); // 15% margin from edges + rectEl.setAttribute("y", "15"); + rectEl.setAttribute("width", "70"); // 70% of viewBox + rectEl.setAttribute("height", "70"); + // rectEl.setAttribute("rx", "8"); // rounded corners (optional) + // rectEl.setAttribute("ry", "8"); + + svgEl.appendChild(rectEl); + svgMethods(svgEl); // assuming you have this + quill.render(svgEl); + return svgEl; +} + +window.Triangle = function (width = "40px", height = "40px") { + const svgNS = "http://www.w3.org/2000/svg" + const svgEl = document.createElementNS(svgNS, "svg") + const pathEl = document.createElementNS(svgNS, "path") + + // SVG size + svgEl.setAttribute("width", width) + svgEl.setAttribute("height", height) + svgEl.setAttribute("viewBox", "0 0 100 100") + svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet") + // Right-pointing triangle (Play icon) + pathEl.setAttribute("d", "M 25 15 L 90 50 L 25 85 Z") // ◄ adjust points if needed + + svgEl.appendChild(pathEl) + svgMethods(svgEl) + quill.render(svgEl) + return svgEl +} + + +/* EVENTS */ + +HTMLElement.prototype.onAppear = function(func) { + func.call(this); + return this; +}; + +HTMLElement.prototype.onClick = function(func) { + const onMouseDown = () => func.call(this, false); + const onMouseUp = () => func.call(this, true); + this._storeListener("mousedown", onMouseDown); + this._storeListener("mouseup", onMouseUp); + return this; +}; + +HTMLElement.prototype.onMouseDown = function(func) { + this._storeListener("mousedown", func); + return this; +}; + +HTMLElement.prototype.onMouseUp = function(func) { + this._storeListener("mouseup", func); + return this; +}; + +HTMLElement.prototype.onRightClick = function(func) { + this._storeListener("contextmenu", func); + return this; +}; + +HTMLElement.prototype.onHover = function(cb) { + const onEnter = (e) => cb.call(this, true, e); + const onLeave = (e) => cb.call(this, false, e); + this._storeListener("mouseover", onEnter); + this._storeListener("mouseleave", onLeave); + return this; +}; + +HTMLElement.prototype.onFocus = function(cb) { + if (!this.matches('input, textarea, select, button')) { + throw new Error("Can't put focus event on non-form element!"); + } + const onFocus = () => cb.call(this, true); + const onBlur = () => cb.call(this, false); + this._storeListener("focus", onFocus); + this._storeListener("blur", onBlur); + return this; +}; + +HTMLElement.prototype.onKeyDown = function(cb) { + this._storeListener("keydown", cb); + return this; +}; + +HTMLElement.prototype.onInput = function(cb) { + if(!this.matches('input, textarea, [contenteditable=""], [contenteditable="true"]')) + throw new Error("Can't put input event on non-input element!") + this._storeListener("input", cb); + return this; +}; + +HTMLElement.prototype.onChange = function(cb) { + if(!this.matches('input, textarea, [contenteditable=""], [contenteditable="true"]')) + throw new Error("Can't put input event on non-input element!") + this._storeListener("change", cb); + return this; +}; + + +HTMLElement.prototype.onSubmit = function(cb) { + if(!this.matches('form')) + throw new Error("Can't put form event on non-form element!") + this._storeListener("submit", cb); + return this; +}; + +HTMLElement.prototype.onTouch = function(cb) { + const onStart = () => cb.call(this, true); + const onEnd = () => cb.call(this, false); + const onCancel = () => cb.call(this, null); + this._storeListener("touchstart", onStart); + this._storeListener("touchend", onEnd); + this._storeListener("touchcancel", onCancel); + return this; +}; + +HTMLElement.prototype.onTap = function(cb) { + this._storeListener("touchend", cb); + return this; +}; + +/* WHY THIS LISTENER IS THE WAY IT IS: +- If we dispatch the "navigate" event on the window (as one would expect for a "navigate" event), a listener placed on the element will not pick it up. +- However, if we add the listener on the window, it won't have the "this" scope that a callback normally would. Which makes it much less useful. +- Then, if we try to add that scope using bind(), it makes the function.toString() unreadable, which means we cannot detect duplicate listeners. +- Therefore, we just have to attach the navigate event to the element, and manually trigger that when the window listener fires. +*/ +navigateListeners = [] +HTMLElement.prototype.onNavigate = function(cb) { + this._storeListener("navigate", cb); + + let found = false + for(entry of navigateListeners) { + if(entry.cb.toString() === cb.toString() && + this.nodeName === entry.el.nodeName) { + found = true + break; + } + } + if(found === false) { + navigateListeners.push({el: this, cb: cb}) + } + + return this; +}; +window.addEventListener("navigate", () => { + for(entry of navigateListeners) { + entry.el.dispatchEvent(new CustomEvent("navigate")) + } +}) + +/* +Same principle applies +*/ +queryListeners = [] +HTMLElement.prototype.onQueryChanged = function(cb) { + this._storeListener("query-changed", cb); + + let found = false + for(entry of queryListeners) { + if(entry.cb.toString() === cb.toString() && + this.nodeName === entry.el.nodeName) { + found = true + break; + } + } + if(found === false) { + queryListeners.push({el: this, cb: cb}) + } + + return this; +}; +window.addEventListener("query-changed", () => { + for(entry of queryListeners) { + entry.el.dispatchEvent(new CustomEvent("query-changed")) + } +}) + +HTMLElement.prototype.onEvent = function(name, cb) { + window._storeListener(window, name, cb); + return this; +}; + +HTMLElement.prototype._storeListener = function(type, handler, options) { + window._storeListener(this, type, handler, options) +} + +window.__listeners = [] + +function _storeListener(target, type, handler, options) { + if (!target.__listeners) target.__listeners = []; + + const optionsString = JSON.stringify(options); + + const index = target.__listeners.findIndex(listener => + listener.type === type && + listener.handler.toString() === handler.toString() && + JSON.stringify(listener.options) === optionsString + ); + + if (index === -1) { // Listener is new + target.addEventListener(type, handler, options); + target.__listeners.push({ type, handler, options }); + } else { // Listener is a duplicate, can be replaced + const old = target.__listeners[index]; + target.removeEventListener(old.type, old.handler, old.options); + + // Replace with the new one + target.addEventListener(type, handler, options); + target.__listeners[index] = { type, handler, options }; + } +} + +HTMLElement.prototype.removeAllListeners = function() { + if (!this.__listeners) return; + for (const { type, handler, options } of this.__listeners) { + this.removeEventListener(type, handler, options); + } + this.__listeners = []; + return this; +}; + +/* ATTRIBUTES */ + +HTMLElement.prototype.attr = function(attributes) { + if ( + typeof attributes !== "object" || + attributes === null || + Array.isArray(attributes) + ) { + throw new TypeError("attr() expects an object with key-value pairs"); + } + + for (const [key, value] of Object.entries(attributes)) { + this.setAttribute(key, value); + } + return this; +}; diff --git a/ui/_/code/shared.css b/ui/_/code/shared.css new file mode 100644 index 0000000..4c1c4ca --- /dev/null +++ b/ui/_/code/shared.css @@ -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); +} diff --git a/ui/_/fonts/BonaNova/BonaNova-Bold.woff b/ui/_/fonts/BonaNova/BonaNova-Bold.woff new file mode 100644 index 0000000..1d01765 Binary files /dev/null and b/ui/_/fonts/BonaNova/BonaNova-Bold.woff differ diff --git a/ui/_/fonts/BonaNova/BonaNova-Italic.woff b/ui/_/fonts/BonaNova/BonaNova-Italic.woff new file mode 100644 index 0000000..940881a Binary files /dev/null and b/ui/_/fonts/BonaNova/BonaNova-Italic.woff differ diff --git a/ui/_/fonts/BonaNova/BonaNova-Regular.woff b/ui/_/fonts/BonaNova/BonaNova-Regular.woff new file mode 100644 index 0000000..b9f826c Binary files /dev/null and b/ui/_/fonts/BonaNova/BonaNova-Regular.woff differ diff --git a/ui/_/fonts/BonaNova/bona-nova-webfont.zip b/ui/_/fonts/BonaNova/bona-nova-webfont.zip new file mode 100644 index 0000000..d80861e Binary files /dev/null and b/ui/_/fonts/BonaNova/bona-nova-webfont.zip differ diff --git a/ui/_/fonts/BonaNova/bona-nova-webfont/example.html b/ui/_/fonts/BonaNova/bona-nova-webfont/example.html new file mode 100644 index 0000000..5220712 --- /dev/null +++ b/ui/_/fonts/BonaNova/bona-nova-webfont/example.html @@ -0,0 +1,18 @@ + + + + + + + + + +

Generated from: http://font.download


+

AaBbCcDdEeFfGgHhŞşIıİi Example

+

AaBbCcDdEeFfGgHhŞşIıİi Example

+

AaBbCcDdEeFfGgHhŞşIıİi Example

+ + + + \ No newline at end of file diff --git a/ui/_/fonts/BonaNova/bona-nova-webfont/style.css b/ui/_/fonts/BonaNova/bona-nova-webfont/style.css new file mode 100644 index 0000000..af76de4 --- /dev/null +++ b/ui/_/fonts/BonaNova/bona-nova-webfont/style.css @@ -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'); + } \ No newline at end of file diff --git a/ui/_/fonts/BonaNova/old/BonaNova-Bold.ttf b/ui/_/fonts/BonaNova/old/BonaNova-Bold.ttf new file mode 100755 index 0000000..805cb80 Binary files /dev/null and b/ui/_/fonts/BonaNova/old/BonaNova-Bold.ttf differ diff --git a/ui/_/fonts/BonaNova/old/BonaNova-Italic.ttf b/ui/_/fonts/BonaNova/old/BonaNova-Italic.ttf new file mode 100755 index 0000000..114dbe5 Binary files /dev/null and b/ui/_/fonts/BonaNova/old/BonaNova-Italic.ttf differ diff --git a/ui/_/fonts/BonaNova/old/BonaNova-Regular.ttf b/ui/_/fonts/BonaNova/old/BonaNova-Regular.ttf new file mode 100755 index 0000000..3387076 Binary files /dev/null and b/ui/_/fonts/BonaNova/old/BonaNova-Regular.ttf differ diff --git a/ui/_/fonts/Canterbury/Canterbury.ttf b/ui/_/fonts/Canterbury/Canterbury.ttf new file mode 100755 index 0000000..f12fa3a Binary files /dev/null and b/ui/_/fonts/Canterbury/Canterbury.ttf differ diff --git a/ui/_/fonts/CrimsonText/CrimsonText-Bold.ttf b/ui/_/fonts/CrimsonText/CrimsonText-Bold.ttf new file mode 100755 index 0000000..5753863 Binary files /dev/null and b/ui/_/fonts/CrimsonText/CrimsonText-Bold.ttf differ diff --git a/ui/_/fonts/CrimsonText/CrimsonText-BoldItalic.ttf b/ui/_/fonts/CrimsonText/CrimsonText-BoldItalic.ttf new file mode 100755 index 0000000..233e387 Binary files /dev/null and b/ui/_/fonts/CrimsonText/CrimsonText-BoldItalic.ttf differ diff --git a/ui/_/fonts/CrimsonText/CrimsonText-Italic.ttf b/ui/_/fonts/CrimsonText/CrimsonText-Italic.ttf new file mode 100755 index 0000000..e8fe8b8 Binary files /dev/null and b/ui/_/fonts/CrimsonText/CrimsonText-Italic.ttf differ diff --git a/ui/_/fonts/CrimsonText/CrimsonText-Regular.ttf b/ui/_/fonts/CrimsonText/CrimsonText-Regular.ttf new file mode 100755 index 0000000..f24bf2a Binary files /dev/null and b/ui/_/fonts/CrimsonText/CrimsonText-Regular.ttf differ diff --git a/ui/_/fonts/CrimsonText/CrimsonText-SemiBold.ttf b/ui/_/fonts/CrimsonText/CrimsonText-SemiBold.ttf new file mode 100755 index 0000000..96f27e8 Binary files /dev/null and b/ui/_/fonts/CrimsonText/CrimsonText-SemiBold.ttf differ diff --git a/ui/_/fonts/CrimsonText/CrimsonText-SemiBoldItalic.ttf b/ui/_/fonts/CrimsonText/CrimsonText-SemiBoldItalic.ttf new file mode 100755 index 0000000..66b129b Binary files /dev/null and b/ui/_/fonts/CrimsonText/CrimsonText-SemiBoldItalic.ttf differ diff --git a/ui/_/fonts/CrimsonText/OFL.txt b/ui/_/fonts/CrimsonText/OFL.txt new file mode 100755 index 0000000..114c104 --- /dev/null +++ b/ui/_/fonts/CrimsonText/OFL.txt @@ -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. diff --git a/ui/desktop/apps/Forum/Forum.js b/ui/desktop/apps/Forum/Forum.js new file mode 100644 index 0000000..0a26881 --- /dev/null +++ b/ui/desktop/apps/Forum/Forum.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/apps/Forum/ForumPanel.js b/ui/desktop/apps/Forum/ForumPanel.js new file mode 100644 index 0000000..d2a6fa4 --- /dev/null +++ b/ui/desktop/apps/Forum/ForumPanel.js @@ -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 { + 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) \ No newline at end of file diff --git a/ui/desktop/apps/Jobs/Jobs.js b/ui/desktop/apps/Jobs/Jobs.js new file mode 100644 index 0000000..bb72598 --- /dev/null +++ b/ui/desktop/apps/Jobs/Jobs.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/apps/Jobs/JobsGrid.js b/ui/desktop/apps/Jobs/JobsGrid.js new file mode 100644 index 0000000..2af5d4f --- /dev/null +++ b/ui/desktop/apps/Jobs/JobsGrid.js @@ -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 `${text}`; + } + return `${text.slice(0, index)}${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) diff --git a/ui/desktop/apps/Jobs/JobsSidebar.js b/ui/desktop/apps/Jobs/JobsSidebar.js new file mode 100644 index 0000000..1546cec --- /dev/null +++ b/ui/desktop/apps/Jobs/JobsSidebar.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/apps/Market/Market.js b/ui/desktop/apps/Market/Market.js new file mode 100644 index 0000000..e3bceeb --- /dev/null +++ b/ui/desktop/apps/Market/Market.js @@ -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) diff --git a/ui/desktop/apps/Market/MarketGrid.js b/ui/desktop/apps/Market/MarketGrid.js new file mode 100644 index 0000000..8740f9e --- /dev/null +++ b/ui/desktop/apps/Market/MarketGrid.js @@ -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 `${text}`; + } + return `${text.slice(0, index)}${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) \ No newline at end of file diff --git a/ui/desktop/apps/Market/MarketSidebar.js b/ui/desktop/apps/Market/MarketSidebar.js new file mode 100644 index 0000000..9324618 --- /dev/null +++ b/ui/desktop/apps/Market/MarketSidebar.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/apps/Messages/Messages.js b/ui/desktop/apps/Messages/Messages.js new file mode 100644 index 0000000..dd288a6 --- /dev/null +++ b/ui/desktop/apps/Messages/Messages.js @@ -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 { + 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) \ No newline at end of file diff --git a/ui/desktop/apps/Messages/MessagesPanel.js b/ui/desktop/apps/Messages/MessagesPanel.js new file mode 100644 index 0000000..b608212 --- /dev/null +++ b/ui/desktop/apps/Messages/MessagesPanel.js @@ -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 { + 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) \ No newline at end of file diff --git a/ui/desktop/apps/Messages/MessagesSidebar.js b/ui/desktop/apps/Messages/MessagesSidebar.js new file mode 100644 index 0000000..453a91d --- /dev/null +++ b/ui/desktop/apps/Messages/MessagesSidebar.js @@ -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 2) { + membersString += member.firstName + } else { + membersString += member.firstName + " " + member.lastName + } + } + return membersString + } +} + +register(MessagesSidebar) \ No newline at end of file diff --git a/ui/desktop/apps/Tasks/Tasks.js b/ui/desktop/apps/Tasks/Tasks.js new file mode 100644 index 0000000..4b0e733 --- /dev/null +++ b/ui/desktop/apps/Tasks/Tasks.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/components/AppMenu.js b/ui/desktop/components/AppMenu.js new file mode 100644 index 0000000..96a507e --- /dev/null +++ b/ui/desktop/components/AppMenu.js @@ -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") \ No newline at end of file diff --git a/ui/desktop/components/AppWindow.js b/ui/desktop/components/AppWindow.js new file mode 100644 index 0000000..3f34b4a --- /dev/null +++ b/ui/desktop/components/AppWindow.js @@ -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") \ No newline at end of file diff --git a/ui/desktop/components/Home.js b/ui/desktop/components/Home.js new file mode 100644 index 0000000..54fe664 --- /dev/null +++ b/ui/desktop/components/Home.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/components/InputBox.js b/ui/desktop/components/InputBox.js new file mode 100644 index 0000000..279a78c --- /dev/null +++ b/ui/desktop/components/InputBox.js @@ -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 */` + + ` + } + + addListeners() { + this.$("textarea").addEventListener("keydown", (e) => { + if(e.key === "Enter") { + e.preventDefault() + e.target.blur() + } + }) + } +} + +customElements.define("input-box", InputBox) \ No newline at end of file diff --git a/ui/desktop/components/LoadingCircle.js b/ui/desktop/components/LoadingCircle.js new file mode 100644 index 0000000..f71a86c --- /dev/null +++ b/ui/desktop/components/LoadingCircle.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/components/ProfileButton.js b/ui/desktop/components/ProfileButton.js new file mode 100644 index 0000000..7ffc029 --- /dev/null +++ b/ui/desktop/components/ProfileButton.js @@ -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) \ No newline at end of file diff --git a/ui/desktop/components/ProfileMenu.js b/ui/desktop/components/ProfileMenu.js new file mode 100644 index 0000000..f46a87e --- /dev/null +++ b/ui/desktop/components/ProfileMenu.js @@ -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") \ No newline at end of file diff --git a/ui/desktop/components/Sidebar.js b/ui/desktop/components/Sidebar.js new file mode 100644 index 0000000..58d1b82 --- /dev/null +++ b/ui/desktop/components/Sidebar.js @@ -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 */ ` + hyperia + + ` + } +} + +customElements.define("side-bar", Sidebar) \ No newline at end of file diff --git a/ui/desktop/index.html b/ui/desktop/index.html new file mode 100644 index 0000000..ca92388 --- /dev/null +++ b/ui/desktop/index.html @@ -0,0 +1,14 @@ + + + + Hyperia + + + + + + + + + + \ No newline at end of file diff --git a/ui/desktop/index.js b/ui/desktop/index.js new file mode 100644 index 0000000..d0acae8 --- /dev/null +++ b/ui/desktop/index.js @@ -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() \ No newline at end of file diff --git a/ui/desktop/util.js b/ui/desktop/util.js new file mode 100644 index 0000000..43c3bd1 --- /dev/null +++ b/ui/desktop/util.js @@ -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(); + } +} \ No newline at end of file diff --git a/ui/desktop/ws/Connection.js b/ui/desktop/ws/Connection.js new file mode 100644 index 0000000..69232ff --- /dev/null +++ b/ui/desktop/ws/Connection.js @@ -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 \ No newline at end of file diff --git a/ui/desktop/ws/Socket.js b/ui/desktop/ws/Socket.js new file mode 100644 index 0000000..49357ef --- /dev/null +++ b/ui/desktop/ws/Socket.js @@ -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 + })); + } +} \ No newline at end of file diff --git a/ui/mobile/apps/Forum/Forum.js b/ui/mobile/apps/Forum/Forum.js new file mode 100644 index 0000000..0a26881 --- /dev/null +++ b/ui/mobile/apps/Forum/Forum.js @@ -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) \ No newline at end of file diff --git a/ui/mobile/apps/Forum/ForumPanel.js b/ui/mobile/apps/Forum/ForumPanel.js new file mode 100644 index 0000000..d2a6fa4 --- /dev/null +++ b/ui/mobile/apps/Forum/ForumPanel.js @@ -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 { + 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) \ No newline at end of file diff --git a/ui/mobile/apps/Jobs/Jobs.js b/ui/mobile/apps/Jobs/Jobs.js new file mode 100644 index 0000000..bb72598 --- /dev/null +++ b/ui/mobile/apps/Jobs/Jobs.js @@ -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) \ No newline at end of file diff --git a/ui/mobile/apps/Jobs/JobsGrid.js b/ui/mobile/apps/Jobs/JobsGrid.js new file mode 100644 index 0000000..2af5d4f --- /dev/null +++ b/ui/mobile/apps/Jobs/JobsGrid.js @@ -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 `${text}`; + } + return `${text.slice(0, index)}${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) diff --git a/ui/mobile/apps/Jobs/JobsSidebar.js b/ui/mobile/apps/Jobs/JobsSidebar.js new file mode 100644 index 0000000..1546cec --- /dev/null +++ b/ui/mobile/apps/Jobs/JobsSidebar.js @@ -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) \ No newline at end of file diff --git a/ui/mobile/apps/Market/Market.js b/ui/mobile/apps/Market/Market.js new file mode 100644 index 0000000..e3bceeb --- /dev/null +++ b/ui/mobile/apps/Market/Market.js @@ -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) diff --git a/ui/mobile/apps/Market/MarketGrid.js b/ui/mobile/apps/Market/MarketGrid.js new file mode 100644 index 0000000..8740f9e --- /dev/null +++ b/ui/mobile/apps/Market/MarketGrid.js @@ -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 `${text}`; + } + return `${text.slice(0, index)}${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) \ No newline at end of file diff --git a/ui/mobile/apps/Market/MarketSidebar.js b/ui/mobile/apps/Market/MarketSidebar.js new file mode 100644 index 0000000..9324618 --- /dev/null +++ b/ui/mobile/apps/Market/MarketSidebar.js @@ -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) \ No newline at end of file diff --git a/ui/mobile/apps/Messages/Messages.js b/ui/mobile/apps/Messages/Messages.js new file mode 100644 index 0000000..dd288a6 --- /dev/null +++ b/ui/mobile/apps/Messages/Messages.js @@ -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 { + 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) \ No newline at end of file diff --git a/ui/mobile/apps/Messages/MessagesPanel.js b/ui/mobile/apps/Messages/MessagesPanel.js new file mode 100644 index 0000000..b608212 --- /dev/null +++ b/ui/mobile/apps/Messages/MessagesPanel.js @@ -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 { + 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) \ No newline at end of file diff --git a/ui/mobile/apps/Messages/MessagesSidebar.js b/ui/mobile/apps/Messages/MessagesSidebar.js new file mode 100644 index 0000000..453a91d --- /dev/null +++ b/ui/mobile/apps/Messages/MessagesSidebar.js @@ -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 2) { + membersString += member.firstName + } else { + membersString += member.firstName + " " + member.lastName + } + } + return membersString + } +} + +register(MessagesSidebar) \ No newline at end of file diff --git a/ui/mobile/apps/Tasks/Tasks.js b/ui/mobile/apps/Tasks/Tasks.js new file mode 100644 index 0000000..4b0e733 --- /dev/null +++ b/ui/mobile/apps/Tasks/Tasks.js @@ -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) \ No newline at end of file diff --git a/ui/mobile/components/AppMenu.js b/ui/mobile/components/AppMenu.js new file mode 100644 index 0000000..7b28205 --- /dev/null +++ b/ui/mobile/components/AppMenu.js @@ -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) \ No newline at end of file diff --git a/ui/mobile/components/Home.js b/ui/mobile/components/Home.js new file mode 100644 index 0000000..0e97d8e --- /dev/null +++ b/ui/mobile/components/Home.js @@ -0,0 +1,12 @@ +import "./AppMenu.js" + +class Home extends Shadow { + + render() { + ZStack(() => { + AppMenu() + }) + } +} + +register(Home) \ No newline at end of file diff --git a/ui/mobile/index.html b/ui/mobile/index.html new file mode 100644 index 0000000..ca92388 --- /dev/null +++ b/ui/mobile/index.html @@ -0,0 +1,14 @@ + + + + Hyperia + + + + + + + + + + \ No newline at end of file diff --git a/ui/mobile/index.js b/ui/mobile/index.js new file mode 100644 index 0000000..d0acae8 --- /dev/null +++ b/ui/mobile/index.js @@ -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() \ No newline at end of file diff --git a/ui/mobile/util.js b/ui/mobile/util.js new file mode 100644 index 0000000..43c3bd1 --- /dev/null +++ b/ui/mobile/util.js @@ -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(); + } +} \ No newline at end of file diff --git a/ui/mobile/ws/Connection.js b/ui/mobile/ws/Connection.js new file mode 100644 index 0000000..69232ff --- /dev/null +++ b/ui/mobile/ws/Connection.js @@ -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 \ No newline at end of file diff --git a/ui/mobile/ws/Socket.js b/ui/mobile/ws/Socket.js new file mode 100644 index 0000000..49357ef --- /dev/null +++ b/ui/mobile/ws/Socket.js @@ -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 + })); + } +} \ No newline at end of file diff --git a/ui/public/components/Footer.js b/ui/public/components/Footer.js new file mode 100644 index 0000000..da9a91a --- /dev/null +++ b/ui/public/components/Footer.js @@ -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) diff --git a/ui/public/components/NavBar.js b/ui/public/components/NavBar.js new file mode 100644 index 0000000..d955e16 --- /dev/null +++ b/ui/public/components/NavBar.js @@ -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) \ No newline at end of file diff --git a/ui/public/components/NavMenu.js b/ui/public/components/NavMenu.js new file mode 100644 index 0000000..c16c506 --- /dev/null +++ b/ui/public/components/NavMenu.js @@ -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 */ ` + + + +

Menu

+ ` + + document.addEventListener("") + } +} + +customElements.define("nav-menu", NavMenu) \ No newline at end of file diff --git a/ui/public/components/SideBar.js b/ui/public/components/SideBar.js new file mode 100644 index 0000000..a16112d --- /dev/null +++ b/ui/public/components/SideBar.js @@ -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 */` +

Menu

+ + + ` + this.querySelector("img").addEventListener("click", () => { + const sidebar = document.querySelector("side-bar"); + sidebar.style.right = "-100vw" + }) + } +} + +customElements.define("side-bar", SideBar) \ No newline at end of file diff --git a/ui/public/components/SignupForm.js b/ui/public/components/SignupForm.js new file mode 100644 index 0000000..47bbcdf --- /dev/null +++ b/ui/public/components/SignupForm.js @@ -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) \ No newline at end of file diff --git a/ui/public/index.html b/ui/public/index.html new file mode 100644 index 0000000..16c4c9c --- /dev/null +++ b/ui/public/index.html @@ -0,0 +1,21 @@ + + + + Hyperia + + + + + + + + + + \ No newline at end of file diff --git a/ui/public/index.js b/ui/public/index.js new file mode 100644 index 0000000..b6b7e32 --- /dev/null +++ b/ui/public/index.js @@ -0,0 +1,2 @@ +import "./pages/Home.js" +Home() \ No newline at end of file diff --git a/ui/public/pages/Events.js b/ui/public/pages/Events.js new file mode 100644 index 0000000..15612d5 --- /dev/null +++ b/ui/public/pages/Events.js @@ -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.
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.

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) \ No newline at end of file diff --git a/ui/public/pages/Home.js b/ui/public/pages/Home.js new file mode 100644 index 0000000..faead38 --- /dev/null +++ b/ui/public/pages/Home.js @@ -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   Y   P   E   R   I   A  ") + .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) \ No newline at end of file diff --git a/ui/public/pages/Join.js b/ui/public/pages/Join.js new file mode 100644 index 0000000..961262f --- /dev/null +++ b/ui/public/pages/Join.js @@ -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) \ No newline at end of file diff --git a/ui/public/pages/SIgnIn.js b/ui/public/pages/SIgnIn.js new file mode 100644 index 0000000..c766caf --- /dev/null +++ b/ui/public/pages/SIgnIn.js @@ -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) \ No newline at end of file diff --git a/ui/public/pages/Success.js b/ui/public/pages/Success.js new file mode 100644 index 0000000..c3753f6 --- /dev/null +++ b/ui/public/pages/Success.js @@ -0,0 +1,9 @@ +class Success extends Shadow { + render() { + p("Thanks for your purchase! You will receive a confirmation email shortly.

Keep that email; it will be checked at the door.") + .x(50, vw).y(50, vh) + .center() + } +} + +register(Success) \ No newline at end of file diff --git a/ui/public/pages/Why.js b/ui/public/pages/Why.js new file mode 100644 index 0000000..f65b12a --- /dev/null +++ b/ui/public/pages/Why.js @@ -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. + +

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. + +

As adults, that is the world the we are all expected to live in. + +

Classical Christian schools are great, but what if I want to live a Classical Christian life? + +

That is what Hyperia is for. It is a Classical Christian space for adults. + +

-- Sam Russell, Founder + `) + .marginTop(window.isMobile() ? 20 : 30, vh) + .marginHorizontal(window.isMobile() ? 10 : 20, vw) + .marginBottom(20, vh) + } +} + +register(Why) \ No newline at end of file diff --git a/ui/public/paintingTags.js b/ui/public/paintingTags.js new file mode 100644 index 0000000..169ed0d --- /dev/null +++ b/ui/public/paintingTags.js @@ -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 = `${paintingName}
${artistName}`; + 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); \ No newline at end of file diff --git a/ui/public/scrollEffect.js b/ui/public/scrollEffect.js new file mode 100644 index 0000000..84b99f2 --- /dev/null +++ b/ui/public/scrollEffect.js @@ -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; + } +} diff --git a/ui/readme.md b/ui/readme.md new file mode 100644 index 0000000..6fde598 --- /dev/null +++ b/ui/readme.md @@ -0,0 +1,5 @@ +# Installs + +Stripe CLI + +stripe listen --forward-to localhost:3003/webhook \ No newline at end of file