This commit is contained in:
Sam
2026-06-10 11:51:56 -05:00
commit 66ba338b81
57 changed files with 5509 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {
"version": "1.0.5",
"resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
"integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
}
}
}

View File

@@ -0,0 +1,6 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
}
}

3
.formatter.exs Normal file
View File

@@ -0,0 +1,3 @@
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/_build/
/deps/
erl_crash.dump
*.ez
.elixir_ls/
priv/logs

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Beam Experiment
The server is a@10.0.0.1. This is on a wireguard network.
## .env
it retrieves environment variables from ../frm.so/.env
## Logs
logs go to /root/beam/prod.log (runtime.exs)
requests go to ./priv/logs/requests.json
## To Run
dev:
```
iex -S mix
```
prod:
```
MIX_ENV=prod elixir --name a@10.0.0.1 --cookie shutupnigga --erl "-detached -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100" -S mix run --no-halt
```
if having problems: run without --detached.
to kill ports:
```
lsof -ti:4000 | xargs kill
lsof -ti:9100 | xargs kill
```
The inet_dist_listen_min part of this is telling it to use port 9100 to listen for outside connections. Otherwise, it would use a random high port. This 9100 is stored in the
# Install Packages
```
mix deps.get
```
# Terminal Commands
```
Node.list()
```
# Connect Remotely
```
iex --name console@10.0.0.2 --cookie shutupnigga --remsh a@10.0.0.1
```

54
admin_htmx.html Normal file
View File

@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>admin</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
body { font: 12px ui-monospace, monospace; margin: 1rem; }
.tabs { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.tabs button {
color: #06c; background: none; border: none; font: inherit;
cursor: pointer; padding: 4px 10px; border-radius: 3px;
}
.tabs button.active { font-weight: bold; color: #000; background: #f0f0f0; cursor: default; }
.toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
.toolbar input[type="text"] {
font: inherit; padding: 2px 6px; border: 1px solid #ccc;
border-radius: 3px; width: 16rem;
}
table { border-collapse: collapse; width: 100%; }
th, td { padding: 4px 8px; border-bottom: 1px solid #eee; text-align: left; vertical-align: top; }
th { background: #f7f7f7; position: sticky; top: 0; }
tr:hover { background: #fafafa; }
.num { text-align: right; }
.mine { background: #fffbe6; }
.name, .mod { color: #06c; }
.src { color: #555; }
.htmx-indicator { color: #888; opacity: 0; transition: opacity 200ms; }
.htmx-request .htmx-indicator { opacity: 1; }
</style>
</head>
<body>
<nav class="tabs">
<button class="active"
onclick="activateTab(this)"
hx-get="/admin_htmx/processes" hx-target="#panel"
hx-trigger="click, load">processes</button>
<button onclick="activateTab(this)"
hx-get="/admin_htmx/modules" hx-target="#panel"
hx-trigger="click">modules</button>
<span class="htmx-indicator"></span>
</nav>
<main id="panel"></main>
<script>
function activateTab(btn) {
for (const b of btn.parentNode.querySelectorAll("button")) {
b.classList.toggle("active", b === btn);
}
}
</script>
</body>
</html>

46
config/runtime.exs Normal file
View File

@@ -0,0 +1,46 @@
# config/runtime.exs
import Config
import Dotenvy
source!([
"../frm.so/.env",
".env.#{config_env()}",
System.get_env()
])
database_url = env!("DATABASE_URL", :string)
%URI{host: db_host, port: db_port, path: db_path, userinfo: db_userinfo} = URI.parse(database_url)
[db_user, db_password] = String.split(db_userinfo || "", ":", parts: 2)
config :forum, Forum.DB,
hostname: db_host,
port: db_port || 5432,
username: db_user,
password: db_password,
database: String.trim_leading(db_path || "", "/"),
pool_size: env!("POOL_SIZE", :integer, 10)
config :forum, :server,
port: env!("PORT", :integer, 4000),
network_port_base: env!("NETWORK_PORT_BASE", :integer, 4001)
config :forum, :auth,
jwt_secret: env!("JWT_SECRET", :string)
# at the bottom of config/runtime.exs
if config_env() == :prod do
config :logger, :default_handler,
config: [
file: ~c"/root/beam/prod.log",
filesync_repeat_interval: 5_000,
max_no_bytes: 10_000_000,
max_no_files: 5,
compress_on_rotate: true
]
config :logger, :default_formatter,
format: "$time [$level] $message\n",
metadata: [:mfa]
config :logger, level: :info
end

126
lib/forum/admin.ex Normal file
View File

@@ -0,0 +1,126 @@
defmodule Forum.Admin do
@moduledoc """
HTML fragments served to the HTMX admin page (/admin_htmx). Two
panels (processes, modules) plus their row-only partials.
"""
# ── processes panel ───────────────────────────────────────────────
def processes_panel do
"""
<section>
<form class="toolbar"
hx-get="/admin_htmx/processes/rows"
hx-target="#p-rows"
hx-trigger="change">
<label><input type="checkbox" name="mine"> mine</label>
<span class="htmx-indicator">…</span>
</form>
<table>
<thead><tr>
<th>pid</th><th>name</th><th>initial call</th>
<th class="num">memory (KB)</th><th class="num">msgs</th><th>status</th>
</tr></thead>
<tbody id="p-rows"
hx-get="/admin_htmx/processes/rows"
hx-trigger="load, every 5s"
hx-include="closest section form"></tbody>
</table>
</section>
"""
end
def processes_rows(rows) do
rows
|> build_tree()
|> render_tree(0)
|> IO.iodata_to_binary()
end
defp build_tree(rows) do
by_parent = Enum.group_by(rows, & &1["parent"])
Map.get(by_parent, nil, []) |> Enum.map(&attach_children(&1, by_parent))
end
defp attach_children(node, by_parent) do
children = Map.get(by_parent, node["pid"], [])
Map.put(node, "children", Enum.map(children, &attach_children(&1, by_parent)))
end
defp render_tree(nodes, depth) do
Enum.map(nodes, fn node ->
[process_row(node, depth) | render_tree(node["children"], depth + 1)]
end)
end
defp process_row(node, depth) do
row_cls = if node["mine"], do: ~s| class="mine"|, else: ""
pad = 8 + depth * 16
name = node["name"]
name_cls = if name, do: ~s| class="name"|, else: ""
[
~s|<tr#{row_cls}>|,
~s|<td style="padding-left:#{pad}px">#{h(node["pid"])}</td>|,
~s|<td#{name_cls}>#{h(name || "-")}</td>|,
~s|<td>#{h(node["initial_call"])}</td>|,
~s|<td class="num">#{node["memory_kb"]}</td>|,
~s|<td class="num">#{node["msgs"]}</td>|,
~s|<td>#{h(node["status"])}</td>|,
"</tr>"
]
end
# ── modules panel ─────────────────────────────────────────────────
def modules_panel do
"""
<section>
<form class="toolbar"
hx-get="/admin_htmx/modules/rows"
hx-target="#m-rows"
hx-trigger="change, input changed delay:200ms from:input[name=q]">
<label><input type="checkbox" name="mine"> mine</label>
<input type="text" name="q" placeholder="filter…" autocomplete="off">
<span class="htmx-indicator">…</span>
</form>
<table>
<thead><tr>
<th>module</th><th>app</th><th>source</th>
</tr></thead>
<tbody id="m-rows"
hx-get="/admin_htmx/modules/rows"
hx-trigger="load"
hx-include="closest section form"></tbody>
</table>
</section>
"""
end
def modules_rows(rows) do
Enum.map_join(rows, "", &module_row/1)
end
defp module_row(r) do
row_cls = if r["mine"], do: ~s| class="mine"|, else: ""
~s|<tr#{row_cls}>| <>
~s|<td class="mod">#{h(r["module"])}</td>| <>
~s|<td>#{h(r["app"] || "-")}</td>| <>
~s|<td class="src">#{h(r["source"] || "(no source)")}</td>| <>
"</tr>"
end
# ── helpers ───────────────────────────────────────────────────────
defp h(text) when is_binary(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&#39;")
end
defp h(other), do: other |> to_string() |> h()
end

72
lib/forum/application.ex Normal file
View File

@@ -0,0 +1,72 @@
# lib/forum/application.ex
defmodule Forum.Application do
use Application
@impl true
def start(_type, _args) do
db_config = Application.fetch_env!(:forum, Forum.DB)
server_config = Application.fetch_env!(:forum, :server)
children =
[
{Postgrex, [name: Forum.DB] ++ db_config},
# Unique process names for forms, networks, public sites, and HTTP servers.
{Registry, keys: :unique, name: Forum.ProcessRegistry},
# WebSocket clients subscribe here for live forms.html updates.
{Registry, keys: :duplicate, name: Forum.FormsSubscriberRegistry},
Forum.LogStore,
Forum.Networks,
Forum.PublicSiteSupervisor,
# Parses forms.html and supervises one QuickBEAM runtime per
# <forms-> child element. Also serves Forum.Forms.html/0.
Forum.Forms,
# Re-parses forms.html whenever the file changes on disk.
Forum.Forms.Watcher,
# Main HTTP + WebSocket server.
http_server_child(:main, Forum.Router, server_config[:port])
] ++ network_site_servers(server_config)
with {:ok, sup} <- Supervisor.start_link(children, strategy: :one_for_one, name: Forum.Supervisor) do
# DynamicSupervisor can't start children from its own init/1, so
# we kick off the initial parse + spawn here.
Forum.Forms.reload()
Forum.Networks.start_networks()
Forum.PublicSiteSupervisor.start_networks()
{:ok, sup}
end
end
defp network_site_servers(server_config) do
base_port = Keyword.get(server_config, :network_port_base, 4001)
Forum.Networks.network_slugs()
|> Enum.with_index()
|> Enum.map(fn {network, index} ->
http_server_child(
{:network, network},
{Forum.PublicSiteRouter, network: network},
base_port + index
)
end)
end
defp http_server_child(key, plug, port) do
Supervisor.child_spec(
{Bandit,
plug: plug,
port: port,
thousand_island_options: [
supervisor_options: [
name: {:via, Registry, {Forum.ProcessRegistry, {:http_server, key}}}
]
]},
id: {:http_server, key, port}
)
end
end

23
lib/forum/assets.ex Normal file
View File

@@ -0,0 +1,23 @@
defmodule Forum.Assets do
@moduledoc false
def path(relative_path) do
source_path = Path.expand(Path.join("priv", relative_path), File.cwd!())
if File.exists?(source_path) do
source_path
else
Application.app_dir(:forum, Path.join("priv", relative_path))
end
end
def paths(relative_glob) do
source_pattern = Path.expand(Path.join("priv", relative_glob), File.cwd!())
release_pattern = Application.app_dir(:forum, Path.join("priv", relative_glob))
case Path.wildcard(source_pattern) do
[] -> Path.wildcard(release_pattern)
paths -> paths
end
end
end

44
lib/forum/db.ex Normal file
View File

@@ -0,0 +1,44 @@
defmodule Forum.DB do
@moduledoc """
Thin wrappers around `Postgrex.query/3`. No ORM, just SQL.
The `Forum.DB` name was registered when Postgrex started in `Application`.
"""
def query(sql, params \\ []) do
Postgrex.query(__MODULE__, sql, params)
end
def query!(sql, params \\ []) do
Postgrex.query!(__MODULE__, sql, params)
end
def list_messages(opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
%Postgrex.Result{rows: rows, columns: cols} =
query!("SELECT id, text, inserted_at FROM messages ORDER BY id DESC LIMIT $1", [limit])
rows_to_maps(rows, cols)
end
def insert_message(text) when is_binary(text) do
%Postgrex.Result{rows: [row], columns: cols} =
query!(
"INSERT INTO messages (text) VALUES ($1) RETURNING id, text, inserted_at",
[text]
)
[map] = rows_to_maps([row], cols)
map
end
# Postgrex returns rows as lists of values + a separate list of column names.
# Zip them into maps so JSON encoding is straightforward.
defp rows_to_maps(rows, cols) do
Enum.map(rows, fn row ->
cols
|> Enum.zip(row)
|> Map.new()
end)
end
end

300
lib/forum/forms.ex Normal file
View File

@@ -0,0 +1,300 @@
defmodule Forum.Forms do
@moduledoc """
Owns the parsed forms document and supervises one QuickBEAM runtime
per element. `DynamicSupervisor` so the tree can change at runtime.
State (parsed Floki tree) lives in the ETS table `:forum_forms`,
owned by the supervisor process. Reads are direct ETS lookups — no
GenServer round-trip — and the table dies cleanly with the supervisor.
Public lifecycle:
Forum.Forms.path/0 — path to the root forms.html
Forum.Forms.reload/0 — re-read priv/forms.html and network forms
Forum.Forms.set_html/1 — replace HTML directly and reconcile
Forum.Forms.add_form/2 — start one runtime ad-hoc
Forum.Forms.remove_form/2 — stop the runtime keyed by {class, id}
Forum.Forms.reconcile/0 — diff stored elements vs running runtimes
Forum.Forms.html/0 — rendered root HTML generated from the Floki tree
Forum.Forms.html/1 — rendered network HTML generated from that network tree
Forum.Forms.tree/0 — parsed Floki tree
Forum.Forms.elements/0 — descriptor maps for <forms-> children
"""
use DynamicSupervisor
require Logger
@table :forum_forms
# ── boot ──────────────────────────────────────────────────────────
def start_link(_opts) do
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(:ok) do
:ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
DynamicSupervisor.init(strategy: :one_for_one)
end
# ── reads ─────────────────────────────────────────────────────────
def html do
render_tree(root_tree())
end
def html(network_slug) when is_binary(network_slug) do
network_slug
|> network_tree()
|> render_tree()
end
def tree do
case :ets.lookup(@table, :tree) do
[{:tree, tree}] -> tree
[] -> []
end
end
def root_tree do
case :ets.lookup(@table, :root_tree) do
[{:root_tree, tree}] -> tree
[] -> []
end
end
def network_tree(network_slug) do
case :ets.lookup(@table, {:network_tree, network_slug}) do
[{{:network_tree, ^network_slug}, tree}] -> tree
[] -> root_tree()
end
end
def elements do
tree()
|> Floki.find("forms- > *")
|> Enum.map(&to_descriptor/1)
end
def path do
Forum.Assets.path("forms.html")
end
def network_paths do
"networks/*/index.html"
|> Forum.Assets.paths()
|> Enum.map(fn path -> {path |> Path.dirname() |> Path.basename(), path} end)
|> Enum.sort()
end
# ── mutations ─────────────────────────────────────────────────────
@doc "Re-read priv/forms.html and priv/networks/*/index.html, store them, reconcile runtimes."
def reload do
root_tree = path() |> File.read!() |> Floki.parse_fragment!()
network_trees =
Enum.map(network_paths(), fn {slug, path} ->
{slug, path |> File.read!() |> Floki.parse_fragment!()}
end)
all_elements =
[root_tree | Enum.map(network_trees, fn {_slug, tree} -> tree end)]
|> Enum.flat_map(&Floki.find(&1, "forms- > *"))
:ets.insert(@table, [
{:tree, wrap_forms(all_elements)},
{:root_tree, root_tree}
| Enum.map(network_trees, fn {slug, tree} -> {{:network_tree, slug}, tree} end)
])
reconcile()
end
@doc "Replace the canonical HTML with `raw` and reconcile runtimes."
def set_html(raw) when is_binary(raw) do
tree = Floki.parse_fragment!(raw)
:ets.insert(@table, [{:tree, tree}, {:root_tree, tree}])
reconcile()
end
@doc "Start one supervised form runtime under `{class_name, id}`."
def add_form(class_name, data) when is_binary(class_name) and is_map(data) do
DynamicSupervisor.start_child(__MODULE__, child_spec_for(class_name, data))
end
@doc "Stop the form runtime registered under `{class_name, id}`."
def remove_form(class_name, id) do
case Registry.lookup(Forum.ProcessRegistry, {:form, {class_name, id}}) do
[{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid)
[] -> {:error, :not_found}
end
end
# ── reconciliation ────────────────────────────────────────────────
@doc """
Diffs stored elements against the currently-running form runtimes:
stops runtimes for elements that disappeared, starts runtimes for
elements that appeared. Same `{class, id}` keeps its existing
runtime even if the element's data changed — call `remove_form/2 +
add_form/2` (or set new HTML containing a different id) to force a
refresh.
"""
def reconcile do
desired =
Map.new(elements(), fn el ->
class = pascal_case(el["tag"])
data = Map.put_new(el["attrs"], "content", el["text"])
id = Map.get(data, "id") || :erlang.unique_integer([:positive])
{{class, id}, {class, data}}
end)
current_keys = MapSet.new(current_form_keys())
desired_keys = MapSet.new(Map.keys(desired))
for {class, id} <- MapSet.difference(current_keys, desired_keys) do
remove_form(class, id)
end
for key <- MapSet.difference(desired_keys, current_keys) do
{class, data} = Map.fetch!(desired, key)
add_form(class, data)
end
Logger.info("Forum.Forms: reconciled, target=#{map_size(desired)} runtimes")
:ok
end
defp current_form_keys do
for {_id, pid, _type, _mods} <- DynamicSupervisor.which_children(__MODULE__),
is_pid(pid),
{:form, key} <- Registry.keys(Forum.ProcessRegistry, pid),
do: key
end
# ── child spec ────────────────────────────────────────────────────
defp child_spec_for(class_name, data) do
id = Map.get(data, "id") || :erlang.unique_integer([:positive])
name = {:via, Registry, {Forum.ProcessRegistry, {:form, {class_name, id}}}}
{QuickBEAM,
name: name,
id: {class_name, id},
define: %{"className" => class_name, "formData" => data},
script: Forum.Assets.path("js/form_init.js")}
end
# ── helpers ───────────────────────────────────────────────────────
defp wrap_forms(elements), do: [{"forms-", [], elements}]
defp render_tree([]), do: ""
defp render_tree(tree) do
tree
|> Floki.find("forms- > *")
|> Enum.map(&to_descriptor/1)
|> Enum.group_by(& &1["tag"])
|> Enum.map_join("\n", fn {tag, descriptors} -> render_table(tag, descriptors) end)
end
defp render_table(tag, descriptors) do
tag_name = String.trim_trailing(tag, "-")
columns = columns_for(descriptors)
header = Enum.map_join(columns, "\n", &render_header/1)
rows = Enum.map_join(descriptors, "\n", &render_row(&1, columns))
"""
<section class="form-table" data-form-tag="#{escape_attr(tag_name)}">
<h2><code>&lt;#{escape_html(tag_name)}-&gt;</code></h2>
<div class="table-wrap">
<table>
<thead>
<tr>
#{header}
</tr>
</thead>
<tbody>
#{rows}
</tbody>
</table>
</div>
</section>
"""
|> String.trim()
end
defp render_header(column) do
" <th>#{escape_html(column)}</th>"
end
defp render_row(descriptor, columns) do
attrs = descriptor["attrs"]
cells =
Enum.map_join(columns, "\n", fn column ->
value = if column == "content", do: descriptor["text"], else: Map.get(attrs, column, "")
" <td>#{escape_html(value)}</td>"
end)
"""
<tr>
#{cells}
</tr>
"""
|> String.trim_trailing()
end
defp columns_for(descriptors) do
attr_columns =
descriptors
|> Enum.flat_map(fn descriptor -> Map.keys(descriptor["attrs"]) end)
|> Enum.uniq()
content_columns =
if Enum.any?(descriptors, &(&1["text"] != "")), do: ["content"], else: []
sort_columns(attr_columns) ++ content_columns
end
defp sort_columns(columns) do
preferred = ["id", "network-id", "name", "title", "email", "key"]
preferred_columns = Enum.filter(preferred, &(&1 in columns))
other_columns = columns -- preferred
preferred_columns ++ Enum.sort(other_columns)
end
defp to_descriptor({tag, attrs, content}) do
text =
content
|> Enum.filter(&is_binary/1)
|> Enum.join("")
|> String.trim()
%{"tag" => tag, "attrs" => Map.new(attrs), "text" => text}
end
defp escape_attr(value) do
value
|> to_string()
|> Plug.HTML.html_escape_to_iodata()
|> IO.iodata_to_binary()
end
defp escape_html(value) do
value
|> to_string()
|> Plug.HTML.html_escape_to_iodata()
|> IO.iodata_to_binary()
end
defp pascal_case(tag) do
tag
|> String.trim_trailing("-")
|> String.split("-")
|> Enum.reject(&(&1 == ""))
|> Enum.map_join("", &String.capitalize/1)
end
end

108
lib/forum/forms/watcher.ex Normal file
View File

@@ -0,0 +1,108 @@
defmodule Forum.Forms.Watcher do
@moduledoc """
Watches the canonical forms.html file and reloads `Forum.Forms` when it changes.
This intentionally uses a small polling loop instead of an external file watcher
dependency so it works the same way in dev, test, and releases.
"""
use GenServer
require Logger
@poll_ms 500
@debounce_ms 200
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
paths = paths()
state = %{paths: paths, signature: signature(paths), debounce_ref: nil}
schedule_poll()
{:ok, state}
end
@impl true
def handle_info(:poll, state) do
paths = paths()
next_signature = signature(paths)
state = %{state | paths: paths}
state =
if next_signature != state.signature do
schedule_reload(%{state | signature: next_signature})
else
state
end
schedule_poll()
{:noreply, state}
end
def handle_info({:reload, signature}, %{signature: signature} = state) do
case reload_forms() do
:ok ->
Logger.info("Forum.Forms.Watcher: reloaded #{Enum.join(state.paths, ", ")}")
broadcast_reload()
{:error, reason} ->
Logger.error("Forum.Forms.Watcher: reload failed: #{inspect(reason)}")
end
{:noreply, %{state | debounce_ref: nil}}
end
def handle_info({:reload, _stale_signature}, state) do
{:noreply, state}
end
defp schedule_reload(%{debounce_ref: ref} = state) when is_reference(ref) do
Process.cancel_timer(ref)
schedule_reload(%{state | debounce_ref: nil})
end
defp schedule_reload(state) do
ref = Process.send_after(self(), {:reload, state.signature}, @debounce_ms)
%{state | debounce_ref: ref}
end
defp schedule_poll do
Process.send_after(self(), :poll, @poll_ms)
end
defp paths do
[Forum.Forms.path() | Enum.map(Forum.Forms.network_paths(), fn {_slug, path} -> path end)]
end
defp signature(paths) do
Enum.map(paths, fn path ->
case File.read(path) do
{:ok, raw} -> {path, :ok, :erlang.phash2(raw), byte_size(raw)}
{:error, reason} -> {path, :error, reason}
end
end)
end
defp reload_forms do
try do
Forum.Forms.reload()
rescue
error -> {:error, error}
catch
kind, reason -> {:error, {kind, reason}}
else
:ok -> :ok
other -> {:error, other}
end
end
defp broadcast_reload do
html = Forum.Forms.html()
for {pid, _value} <- Registry.lookup(Forum.FormsSubscriberRegistry, :forms) do
send(pid, {:forms_reloaded, html})
end
end
end

61
lib/forum/log_store.ex Normal file
View File

@@ -0,0 +1,61 @@
defmodule Forum.LogStore do
@moduledoc """
Persists request and hosting logs to a JSON file.
"""
use GenServer
@max_entries 1_000
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def add(entry) when is_map(entry) do
GenServer.cast(__MODULE__, {:add, entry})
end
def list(limit \\ 200) do
GenServer.call(__MODULE__, {:list, limit})
end
@impl true
def init(_opts) do
path = Path.expand("priv/logs/requests.json", File.cwd!())
File.mkdir_p!(Path.dirname(path))
{:ok, %{path: path, entries: read_entries(path)}}
end
@impl true
def handle_cast({:add, entry}, state) do
entry =
entry
|> Map.put_new("id", System.unique_integer([:positive, :monotonic]))
|> Map.put_new("time", DateTime.utc_now() |> DateTime.to_iso8601())
entries = [entry | state.entries] |> Enum.take(@max_entries)
write_entries!(state.path, entries)
{:noreply, %{state | entries: entries}}
end
@impl true
def handle_call({:list, limit}, _from, state) do
{:reply, Enum.take(state.entries, limit), state}
end
defp read_entries(path) do
with {:ok, raw} <- File.read(path),
{:ok, entries} when is_list(entries) <- Jason.decode(raw) do
entries
else
_ -> []
end
end
defp write_entries!(path, entries) do
tmp_path = path <> ".tmp"
File.write!(tmp_path, Jason.encode!(entries, pretty: true))
File.rename!(tmp_path, path)
end
end

44
lib/forum/modules.ex Normal file
View File

@@ -0,0 +1,44 @@
defmodule Forum.Modules do
@moduledoc """
Snapshot of every loaded Erlang/Elixir module with its OTP app and
source file. Powers the /modules endpoint.
"""
@doc """
Returns a list of maps, one per loaded module, sorted by module name.
Each map has string keys (JSON-friendly):
"module" — module name, e.g. "Elixir.Forum.Router"
"app" — OTP app the module belongs to, e.g. ":forum", or nil
"source" — absolute path to the source file at compile time, or nil
"mine" — true if the module belongs to this app
"""
def list do
my_modules = MapSet.new(Application.spec(:forum, :modules) || [])
:code.all_loaded()
|> Enum.map(fn {mod, _beam} ->
%{
"module" => inspect(mod),
"app" => format_app(Application.get_application(mod)),
"source" => source_for(mod),
"mine" => MapSet.member?(my_modules, mod)
}
end)
|> Enum.sort_by(& &1["module"])
end
defp format_app(nil), do: nil
defp format_app(app) when is_atom(app), do: inspect(app)
defp source_for(mod) do
case Keyword.get(mod.module_info(:compile), :source) do
nil -> nil
source when is_list(source) -> List.to_string(source)
source when is_binary(source) -> source
_ -> nil
end
rescue
_ -> nil
end
end

161
lib/forum/network.ex Normal file
View File

@@ -0,0 +1,161 @@
defmodule Forum.Network do
@moduledoc """
A per-network process for serving the app's forms view.
Requests to `/` use the root forms. Requests to `/<network>` use the
matching network forms from `priv/networks/<network>/index.html`.
"""
use GenServer
@default_hosts MapSet.new(["localhost", "127.0.0.1", "0.0.0.0", ""])
def child_spec(opts) do
slug = opts |> Keyword.fetch!(:slug) |> normalize_slug()
%{
id: {:network, slug},
start: {__MODULE__, :start_link, [[slug: slug]]},
restart: :permanent,
shutdown: 5_000,
type: :worker
}
end
def start_link(opts) do
slug = Keyword.fetch!(opts, :slug)
GenServer.start_link(__MODULE__, slug, name: via(slug))
end
def normalize_slug(slug) when is_binary(slug) do
slug
|> String.downcase()
|> String.split(":", parts: 2)
|> hd()
end
def serve(pid, request) do
GenServer.call(pid, {:serve, request})
end
@impl true
def init(slug) do
{:ok, %{slug: normalize_slug(slug), root: site_root(slug), started_at: DateTime.utc_now()}}
end
@impl true
def handle_call({:serve, request}, _from, state) do
response = do_serve(state, request)
{:reply, response, state}
end
defp do_serve(%{slug: slug} = state, request) do
path = Map.fetch!(request, :path)
cond do
default_site?(slug) and path in ["", "/"] ->
desktop_response()
network_site?(slug) and path in ["", "/"] ->
network_forms_response(slug)
File.dir?(state.root) ->
static_response(state.root, path)
true ->
%{
status: 404,
content_type: "text/plain",
body:
"No network configured for #{slug}. Create files under #{Path.relative_to_cwd(state.root)}."
}
end
end
defp desktop_response do
body =
Forum.Assets.path("ui/desktop.html")
|> File.read!()
|> String.replace("<!-- FORMS_HTML -->", Forum.Forms.html())
%{status: 200, content_type: "text/html", body: body}
end
defp network_forms_response(slug) do
body =
Forum.Assets.path("ui/desktop.html")
|> File.read!()
|> String.replace("<!-- FORMS_HTML -->", Forum.Forms.html(slug))
%{status: 200, content_type: "text/html", body: body}
end
defp static_response(root, path) do
file_path = resolve_path(root, path)
cond do
file_path == :invalid ->
%{status: 403, content_type: "text/plain", body: "Forbidden"}
File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) ->
send_file(Path.join(file_path, "index.html"))
File.regular?(file_path) ->
send_file(file_path)
true ->
%{status: 404, content_type: "text/plain", body: "Not found"}
end
end
defp resolve_path(root, path) do
relative =
path
|> String.trim_leading("/")
|> URI.decode()
candidate = Path.expand(relative, root)
rel_to_root = Path.relative_to(candidate, Path.expand(root))
if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do
:invalid
else
candidate
end
end
defp send_file(path) do
%{status: 200, content_type: content_type(path), body: File.read!(path)}
end
defp content_type(path) do
case Path.extname(path) do
".html" -> "text/html"
".css" -> "text/css"
".js" -> "application/javascript"
".json" -> "application/json"
".svg" -> "image/svg+xml"
".png" -> "image/png"
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".webp" -> "image/webp"
".gif" -> "image/gif"
".ico" -> "image/x-icon"
".txt" -> "text/plain"
_ -> "application/octet-stream"
end
end
defp site_root(slug) do
source_path = Path.expand(Path.join(["priv", "networks", normalize_slug(slug)]), File.cwd!())
if File.exists?(source_path) do
source_path
else
Application.app_dir(:forum, Path.join(["priv", "networks", normalize_slug(slug)]))
end
end
defp default_site?(slug), do: MapSet.member?(@default_hosts, slug)
defp network_site?(slug), do: slug in Forum.Networks.network_slugs()
defp via(slug), do: {:via, Registry, {Forum.ProcessRegistry, {:network, normalize_slug(slug)}}}
end

View File

@@ -0,0 +1,56 @@
defmodule Forum.Networks do
@moduledoc """
Starts one network process for each forms network the app serves.
"""
use DynamicSupervisor
def start_link(opts) do
DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def get_or_start(slug) when is_binary(slug) do
key = Forum.Network.normalize_slug(slug)
case Registry.lookup(Forum.ProcessRegistry, {:network, key}) do
[{pid, _}] ->
{:ok, pid}
[] ->
spec = {Forum.Network, slug: key}
case DynamicSupervisor.start_child(__MODULE__, spec) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
other -> other
end
end
end
def networks do
Forum.ProcessRegistry
|> Registry.select([
{{{:network, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}
])
end
def start_networks do
for slug <- network_slugs() do
get_or_start(slug)
end
:ok
end
def network_slugs do
"networks/*"
|> Forum.Assets.paths()
|> Enum.filter(&File.dir?/1)
|> Enum.map(&Path.basename/1)
|> Enum.sort()
end
end

175
lib/forum/processes.ex Normal file
View File

@@ -0,0 +1,175 @@
defmodule Forum.Processes do
@moduledoc """
Snapshot of every BEAM process for the /processes endpoint. Pure data
function — the router serves a shell HTML and the WS handler returns
the rows produced here.
"""
@doc """
Returns a list of maps, one per live process, sorted by memory desc.
Each map has string keys (JSON-friendly):
"pid" — inspected PID string, e.g. "#PID<0.123.0>"
"parent" — inspected PID string of immediate parent (from
$ancestors), or nil for roots / non-proc_lib processes
"name" — registered name string, or nil
"initial_call" — real $initial_call MFA, formatted as "Mod.fun/arity"
"memory_kb" — process memory in KB
"tree_memory_kb" — process memory plus all descendant process memory in KB
"msgs" — message queue length
"status" — :running | :runnable | :waiting | :suspended | :exiting
"mine" — true if the process is under Forum.Supervisor's tree
"""
# Registries to consult for "via" names — processes registered as
# {:via, Registry, {Foo, key}} don't show up under :registered_name,
# so we look them up by pid here and use the key as the display name.
@registries [
Forum.ProcessRegistry,
Forum.FormsSubscriberRegistry
]
def list do
my_pids = my_pids()
Process.list()
|> Enum.map(&info_for(&1, my_pids))
|> Enum.reject(&is_nil/1)
|> add_tree_memory()
|> Enum.sort_by(& &1["memory_kb"], :desc)
end
defp add_tree_memory(rows) do
by_parent = Enum.group_by(rows, & &1["parent"])
{totals, _memo} =
Enum.map_reduce(rows, %{}, fn row, memo ->
{total, memo} = tree_memory(row, by_parent, memo)
{{row["pid"], total}, memo}
end)
totals = Map.new(totals)
Enum.map(rows, fn row ->
Map.put(row, "tree_memory_kb", Map.fetch!(totals, row["pid"]))
end)
end
defp tree_memory(row, by_parent, memo) do
pid = row["pid"]
case memo do
%{^pid => total} ->
{total, memo}
%{} ->
children = Map.get(by_parent, pid, [])
{children_total, memo} =
Enum.reduce(children, {0, memo}, fn child, {sum, memo} ->
{total, memo} = tree_memory(child, by_parent, memo)
{sum + total, memo}
end)
total = row["memory_kb"] + children_total
{total, Map.put(memo, pid, total)}
end
end
defp info_for(pid, my_pids) do
case Process.info(pid, [
:registered_name,
:initial_call,
:memory,
:message_queue_len,
:status,
:dictionary
]) do
nil ->
nil
info ->
dict = info[:dictionary] || []
real_call = Keyword.get(dict, :"$initial_call") || info[:initial_call]
ancestors = Keyword.get(dict, :"$ancestors", [])
name =
format_name(info[:registered_name]) || lookup_registry_name(pid)
%{
"pid" => inspect(pid),
"parent" => parent_string(ancestors),
"name" => name,
"initial_call" => format_mfa(real_call),
"memory_kb" => div(info[:memory] || 0, 1024),
"msgs" => info[:message_queue_len] || 0,
"status" => info[:status],
"mine" => MapSet.member?(my_pids, pid)
}
end
end
defp lookup_registry_name(pid) do
@registries
|> Enum.flat_map(fn registry ->
try do
Registry.keys(registry, pid) |> Enum.map(&format_registry_key/1)
rescue
# Registry not started yet (boot race) — skip silently.
ArgumentError -> []
end
end)
|> case do
[] -> nil
names -> Enum.join(names, ", ")
end
end
# {"Task", "1"} -> "Task#1". Falls back to inspect for unknown shapes.
defp format_registry_key({:form, {class, id}}) when is_binary(class), do: "#{class}##{id}"
defp format_registry_key({:network, slug}), do: "network:#{slug}"
defp format_registry_key({:public_site, network}), do: "public-site:#{network}"
defp format_registry_key({:http_server, :main}), do: "http-server:main"
defp format_registry_key({:http_server, {:network, network}}), do: "http-server:#{network}"
defp format_registry_key(:forms), do: "forms-subscriber:forms"
defp format_registry_key(other), do: inspect(other)
# The first entry in $ancestors is the immediate parent. proc_lib
# stores PIDs directly; for processes registered by name it can also
# be the registered name (atom), which we resolve to its current PID.
defp parent_string([pid | _]) when is_pid(pid), do: inspect(pid)
defp parent_string([name | _]) when is_atom(name) do
case Process.whereis(name) do
nil -> nil
pid -> inspect(pid)
end
end
defp parent_string(_), do: nil
defp my_pids do
case Process.whereis(Forum.Supervisor) do
nil -> MapSet.new()
sup -> MapSet.new([sup | all_descendants(sup)])
end
end
defp all_descendants(sup) do
Supervisor.which_children(sup)
|> Enum.flat_map(fn {_id, pid, type, _modules} ->
cond do
not is_pid(pid) -> []
type == :supervisor -> [pid | all_descendants(pid)]
true -> [pid]
end
end)
end
defp format_name([]), do: nil
defp format_name(name) when is_atom(name), do: inspect(name)
defp format_name(_), do: nil
defp format_mfa({m, f, a}) when is_atom(m) and is_atom(f) and is_integer(a),
do: "#{inspect(m)}.#{f}/#{a}"
defp format_mfa(_), do: "-"
end

182
lib/forum/public_site.ex Normal file
View File

@@ -0,0 +1,182 @@
defmodule Forum.PublicSite do
@moduledoc """
A per-network process for serving public static websites on separate ports.
"""
use GenServer
def child_spec(opts) do
network = opts |> Keyword.fetch!(:network) |> normalize_network()
%{
id: {:public_site, network},
start: {__MODULE__, :start_link, [[network: network]]},
restart: :permanent,
shutdown: 5_000,
type: :worker
}
end
def start_link(opts) do
network = Keyword.fetch!(opts, :network)
GenServer.start_link(__MODULE__, network, name: via(network))
end
def normalize_network(network) when is_binary(network) do
network
|> String.downcase()
|> String.split(":", parts: 2)
|> hd()
end
def serve(pid, request) do
GenServer.call(pid, {:serve, request})
end
@impl true
def init(network) do
{:ok, %{network: normalize_network(network), started_at: DateTime.utc_now()}}
end
@impl true
def handle_call({:serve, request}, _from, state) do
response = do_serve(state, request)
{:reply, response, state}
end
defp do_serve(%{network: network}, request) do
roots = public_roots(network)
case roots do
[] ->
%{
status: 404,
content_type: "text/plain",
body: "No public website configured for #{network}"
}
[primary | fallbacks] ->
static_response(primary, request.path, fallbacks)
end
end
defp static_response(root, path, fallback_roots \\ []) do
file_path = resolve_path(root, path)
cond do
file_path == :invalid ->
%{status: 403, content_type: "text/plain", body: "Forbidden"}
File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) ->
send_file(Path.join(file_path, "index.html"))
File.regular?(file_path) ->
send_file(file_path)
versioned = find_versioned_file([root | fallback_roots], path) ->
send_file(versioned)
fallback = find_fallback_file(fallback_roots, path) ->
send_file(fallback)
true ->
%{status: 404, content_type: "text/plain", body: "Not found"}
end
end
defp find_versioned_file(roots, path) do
case path |> String.trim_leading("/") |> Path.split() do
[_version | rest] when rest != [] ->
stripped_path = "/" <> Path.join(rest)
find_fallback_file(roots, stripped_path)
_ ->
nil
end
end
defp find_fallback_file(roots, path) do
Enum.find_value(roots, fn root ->
file_path = resolve_path(root, path)
cond do
file_path == :invalid ->
nil
File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) ->
Path.join(file_path, "index.html")
File.regular?(file_path) ->
file_path
true ->
nil
end
end)
end
defp resolve_path(root, path) do
relative =
path
|> String.trim_leading("/")
|> URI.decode()
candidate = Path.expand(relative, root)
rel_to_root = Path.relative_to(candidate, Path.expand(root))
if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do
:invalid
else
candidate
end
end
defp send_file(path) do
%{status: 200, content_type: content_type(path), body: File.read!(path)}
end
defp content_type(path) do
case Path.extname(path) do
".html" -> "text/html"
".css" -> "text/css"
".js" -> "application/javascript"
".json" -> "application/json"
".svg" -> "image/svg+xml"
".png" -> "image/png"
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".webp" -> "image/webp"
".gif" -> "image/gif"
".ico" -> "image/x-icon"
".txt" -> "text/plain"
_ -> "application/octet-stream"
end
end
defp public_roots("cs") do
["networks/cs/website"]
|> Enum.map(&Forum.Assets.path/1)
|> Enum.filter(&File.dir?/1)
end
defp public_roots("comalyr") do
[
"networks/comalyr/comalyr.com/ui/desktop",
"networks/comalyr/comalyr.com/ui"
]
|> Enum.map(&Forum.Assets.path/1)
|> Enum.filter(&File.dir?/1)
end
defp public_roots(network) do
[
Path.join(["networks", network, "website"]),
Path.join(["networks", network, "public"]),
Path.join(["networks", network, "www"])
]
|> Enum.map(&Forum.Assets.path/1)
|> Enum.filter(&File.dir?/1)
end
defp via(network),
do: {:via, Registry, {Forum.ProcessRegistry, {:public_site, normalize_network(network)}}}
end

View File

@@ -0,0 +1,60 @@
defmodule Forum.PublicSiteRouter do
@moduledoc """
Serves one network's public website on its own port.
"""
import Plug.Conn
def init(opts), do: opts
def call(conn, opts) do
network = Keyword.fetch!(opts, :network)
started_at = System.monotonic_time(:microsecond)
response =
with {:ok, pid} <- Forum.PublicSiteSupervisor.get_or_start(network) do
Forum.PublicSite.serve(pid, %{
method: conn.method,
path: conn.request_path,
query_string: conn.query_string,
headers: conn.req_headers
})
else
{:error, reason} ->
%{
status: 500,
content_type: "text/plain",
body: "Unable to start public site: #{inspect(reason)}"
}
end
duration_us = System.monotonic_time(:microsecond) - started_at
Forum.LogStore.add(%{
"source_ip" => remote_ip(conn),
"host" => conn.host || "",
"network" => network,
"method" => conn.method,
"path" => conn.request_path,
"query_string" => conn.query_string,
"status" => response.status,
"duration_ms" => Float.round(duration_us / 1_000, 2)
})
conn
|> put_resp_content_type(response.content_type)
|> send_resp(response.status, response.body)
end
defp remote_ip(conn) do
case Plug.Conn.get_req_header(conn, "x-real-ip") do
[ip | _] -> ip
[] -> fallback_ip(conn)
end
end
defp fallback_ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
remote_ip |> :inet.ntoa() |> to_string()
end
defp fallback_ip(_), do: ""
end

View File

@@ -0,0 +1,48 @@
defmodule Forum.PublicSiteSupervisor do
@moduledoc """
Starts one public-site process for each network with a separate port.
"""
use DynamicSupervisor
def start_link(opts) do
DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def get_or_start(network) when is_binary(network) do
key = Forum.PublicSite.normalize_network(network)
case Registry.lookup(Forum.ProcessRegistry, {:public_site, key}) do
[{pid, _}] ->
{:ok, pid}
[] ->
spec = {Forum.PublicSite, network: key}
case DynamicSupervisor.start_child(__MODULE__, spec) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
other -> other
end
end
end
def public_sites do
Forum.ProcessRegistry
|> Registry.select([
{{{:public_site, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}
])
end
def start_networks do
for slug <- Forum.Networks.network_slugs() do
get_or_start(slug)
end
:ok
end
end

125
lib/forum/router.ex Normal file
View File

@@ -0,0 +1,125 @@
defmodule Forum.Router do
use Plug.Router
use Joken.Config
require Logger
plug Plug.Logger
plug :log_request
plug :fetch_cookies
plug :verify_session_host
plug :match
plug :dispatch
defp verify_session_host(conn, _opts) do
if conn.host == "session.frm.so" do
case authenticate(conn) do
{:ok, claims} ->
conn
|> Plug.Conn.assign(:claims, claims)
:error ->
conn |> send_resp(401, "unauthorized") |> halt()
end
else
conn
end
end
defp authenticate(conn) do
with token when is_binary(token) <- conn.cookies["auth_token"], {:ok, claims} <- verify_token(token) do
{:ok, claims}
else
_ -> :error
end
end
defp verify_token(token) do
secret = Application.fetch_env!(:forum, :auth)[:jwt_secret]
signer = Joken.Signer.create("HS256", secret)
case Joken.verify_and_validate(%{}, token, signer) do
{:ok, claims} -> {:ok, claims}
{:error, _reason} = err -> err
end
end
get "/admin", host: "session.frm.so", do: handle_admin(conn)
get "/admin/graphyellow.svg", host: "session.frm.so", do: admin_asset(conn, "ui/graphyellow.svg", "image/svg+xml")
get "/admin/*path", host: "session.frm.so", do: handle_admin(conn)
get "/", host: "session.frm.so" do
user_id = conn.assigns.claims["id"]
email = conn.assigns.claims["email"]
conn
|> WebSockAdapter.upgrade(Forum.WsHandler, %{user_id: user_id, email: email}, timeout: :infinity)
|> halt()
end
match _ do
send_resp(conn, 404, "not found")
end
defp handle_admin(conn) do
if websocket_upgrade?(conn) do
conn
|> WebSockAdapter.upgrade(Forum.WsHandler, %{}, timeout: :infinity)
|> halt()
else
admin_page(conn)
end
end
defp websocket_upgrade?(conn) do
connection = get_req_header(conn, "connection") |> Enum.join(",") |> String.downcase()
upgrade = get_req_header(conn, "upgrade") |> List.first() |> to_string() |> String.downcase()
String.contains?(connection, "upgrade") and upgrade == "websocket"
end
defp admin_page(conn) do
html = File.read!(Forum.Assets.path("ui/admin.html"))
conn
|> put_resp_content_type("text/html")
|> send_resp(200, html)
end
defp admin_asset(conn, path, content_type) do
conn
|> put_resp_content_type(content_type)
|> send_file(200, Forum.Assets.path(path))
end
defp log_request(conn, _opts) do
started_at = System.monotonic_time(:microsecond)
Plug.Conn.register_before_send(conn, fn conn ->
duration_us = System.monotonic_time(:microsecond) - started_at
Forum.LogStore.add(%{
"source_ip" => remote_ip(conn),
"host" => conn.host || "",
"method" => conn.method,
"path" => conn.request_path,
"query_string" => conn.query_string,
"status" => conn.status || 0,
"duration_ms" => Float.round(duration_us / 1_000, 2)
})
conn
end)
end
defp remote_ip(conn) do
case Plug.Conn.get_req_header(conn, "x-real-ip") do
[ip | _] -> ip
[] -> fallback_ip(conn)
end
end
defp fallback_ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
remote_ip |> :inet.ntoa() |> to_string()
end
defp fallback_ip(_), do: ""
end

24
lib/forum/vm_memory.ex Normal file
View File

@@ -0,0 +1,24 @@
defmodule Forum.VmMemory do
@moduledoc """
Snapshot of BEAM VM memory categories.
These numbers come from `:erlang.memory/0`, so they describe memory held by
the whole VM rather than memory attributed to a particular Erlang process.
"""
@doc """
Returns VM memory categories as JSON-friendly maps.
"""
def list do
:erlang.memory()
|> Enum.map(fn {category, bytes} ->
%{
"category" => Atom.to_string(category),
"bytes" => bytes,
"kb" => div(bytes, 1024),
"mb" => Float.round(bytes / 1_048_576, 2)
}
end)
|> Enum.sort_by(& &1["bytes"], :desc)
end
end

110
lib/forum/ws_handler.ex Normal file
View File

@@ -0,0 +1,110 @@
defmodule Forum.WsHandler do
@moduledoc """
One instance of this module runs per connected WebSocket client.
Each instance is its own BEAM process — isolated state, isolated crashes.
Client messages arrive in `handle_in/2`. Reply with `{:push, frame, state}`,
do nothing with `{:ok, state}`, or close with `{:stop, reason, state}`.
"""
@behaviour WebSock
require Logger
@impl true
def init(opts) do
Registry.register(Forum.FormsSubscriberRegistry, :forms, nil)
Logger.info("ws connected: user_id=#{opts[:user_id]}")
state = %{
message_count: 0,
user_id: opts[:user_id],
email: opts[:email]
}
welcome = Jason.encode!(%{
type: "welcome to this session: ",
user_id: opts[:user_id]
})
schedule_heartbeat()
{:push, {:text, welcome}, state}
end
# Text frame from client.
# Supported commands (sent as JSON over the wire):
# {"type": "ping"} -> {"type": "pong"}
# {"type": "send", "text": "hi"} -> inserts row, returns the row
# {"type": "list"} -> returns latest 50 rows
# {"type": "get_doc"} -> returns the backend body HTML
# {"type": "reload_forms"} -> re-reads forms.html and reconciles runtimes
# {"type": "list_processes"} -> returns a snapshot of all live processes
# {"type": "list_logs"} -> returns recent request logs
# {"type": "list_vm_memory"} -> returns BEAM VM memory categories
# Anything else echoes back.
@impl true
def handle_in({raw, [opcode: :text]}, state) do
state = %{state | message_count: state.message_count + 1}
response =
case Jason.decode(raw) do
{:ok, %{"type" => "ping"}} ->
%{type: "pong"}
{:ok, %{"type" => "send", "text" => text}} when is_binary(text) and text != "" ->
row = Forum.DB.insert_message(text)
%{type: "inserted", row: row}
{:ok, %{"type" => "list"}} ->
%{type: "list", rows: Forum.DB.list_messages(limit: 50)}
{:ok, %{"type" => "get_doc"}} ->
%{type: "doc", html: Forum.Forms.html()}
{:ok, %{"type" => "reload_forms"}} ->
:ok = Forum.Forms.reload()
%{type: "doc", html: Forum.Forms.html()}
{:ok, %{"type" => "list_processes"}} ->
%{type: "processes", rows: Forum.Processes.list()}
{:ok, %{"type" => "list_modules"}} ->
%{type: "modules", rows: Forum.Modules.list()}
{:ok, %{"type" => "list_logs"}} ->
%{type: "logs", rows: Forum.LogStore.list()}
{:ok, %{"type" => "list_vm_memory"}} ->
%{type: "vm_memory", rows: Forum.VmMemory.list()}
_ ->
%{type: "echo", raw: raw, count: state.message_count}
end
{:push, {:text, Jason.encode!(response)}, state}
end
# Binary frame from client — ignore in this scaffold.
def handle_in({_data, [opcode: :binary]}, state), do: {:ok, state}
# Messages sent to this process's mailbox from other processes.
@impl true
def handle_info({:forms_reloaded, html}, state) do
{:push, {:text, Jason.encode!(%{type: "doc", html: html})}, state}
end
def handle_info(_msg, state), do: {:ok, state}
def handle_info(:heartbeat, state) do
schedule_heartbeat()
{:push, {:ping, ""}, state}
end
defp schedule_heartbeat, do: Process.send_after(self(), :heartbeat, 30_000)
@impl true
def terminate(reason, _state) do
Logger.info("ws disconnected: #{inspect(reason)}")
:ok
end
end

33
mix.exs Normal file
View File

@@ -0,0 +1,33 @@
defmodule Forum.MixProject do
use Mix.Project
def project do
[
app: :forum,
version: "0.1.0",
elixir: "~> 1.16",
deps: deps()
]
end
def application do
[
mod: {Forum.Application, []},
extra_applications: [:logger, :crypto, :inets, :ssl]
]
end
defp deps do
[
{:bandit, "~> 1.5"},
{:plug, "~> 1.16"},
{:websock_adapter, "~> 0.5"},
{:postgrex, "~> 0.19"},
{:jason, "~> 1.4"},
{:dotenvy, "~> 0.9.0"},
{:quickbeam, "~> 0.10.10"},
{:floki, "~> 0.36"},
{:joken, "~> 2.6"}
]
end
end

27
mix.lock Normal file
View File

@@ -0,0 +1,27 @@
%{
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"dotenvy": {:hex, :dotenvy, "0.9.0", "aad823209cd7c13babe2dc310d9e54ce0203674cbd7631b0ced2a771e3a49532", [:mix], [], "hexpm", "ab959208a9ad02ff26ce1c5d4911668925c12a6cf58287ef77ae63161909c73b"},
"floki": {:hex, :floki, "0.38.3", "40d291831d93f49aa360f09447cf2e2a902e33d8711e5fb22a75f3f333e9d063", [:mix], [], "hexpm", "025aa1f5f24a70cb31bfbc7011419228596f3b062d7feda617238ba4926f83cb"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oxc": {:hex, :oxc, "0.15.1", "d4b1770f26cd80b84ac592a54db38c50af92c98ce480be2ae1badbc0eccd614e", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "1008f80d04b961f5f262a0aec99781f3a73e5c4c7fc969f4336ca96f653c7d58"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"quickbeam": {:hex, :quickbeam, "0.10.15", "8622e090ba8476b4a7c008ddb75c741e770f45e64f9fc30c43b02694213a8605", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.15.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2da3750f1e99ae1d97b6e99a2b8ea86ba48c49c568d8f711d65d342430dcfb86"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
"zigler_precompiled": {:hex, :zigler_precompiled, "0.1.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"},
}

319
priv/experiments/forms.pug Normal file
View File

@@ -0,0 +1,319 @@
forms-
// global
member-(id="1" email="samrussell99@pm.me" first-name="Sam" last-name="Russell" password="$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="CEO" bio="This is my bio" notes="no notes" created="2026-01-15 09:58:01.0072" updated-at="2026-01-15 09:58:01.0072")
member-(id="2" email="freddyjkrueger@gmail.com" first-name="Freddy" last-name="Krueger" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Villain" bio="This is my bio" notes="no notes" created="2026-01-13 13:38:46.0810" updated-at="2026-01-13 13:38:46.0810")
member-(id="3" email="harmysmarmy@gmail.com" first-name="Harmy" last-name="Smarmy" password="$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-01-13 13:41:41.0722" updated-at="2026-01-13 13:41:41.0722")
member-(id="4" email="matiascarulli@gmail.com" first-name="Matias" last-name="Carulli" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 address NW 12th St" city="Miramar" state="Florida" zipcode="33029" country="US" county="Broward" phone="123-456-789" title="Developer" bio="This is my bio" image-path="/db/images/users/member-4/profile.png" notes="no notes" created="2026-03-15 13:41:41.0722" updated-at="2026-03-15 13:41:41.0722")
member-(id="5" email="boulder@example.com" first-name="CU" last-name="Boulder" password="$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-03-26 01:03:18.803016+00" updated-at="2026-03-26 01:03:18.803016+00")
member-(id="6" email="sarah.mcintyre@example.com" first-name="Sarah" last-name="McIntyre" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00")
member-(id="7" email="marcus.webb@example.com" first-name="Marcus" last-name="Webb" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Engineer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00")
member-(id="8" email="priya.anand@example.com" first-name="Priya" last-name="Anand" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="PM" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00")
member-(id="9" email="jordan.kim@example.com" first-name="Jordan" last-name="Kim" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00")
app-(id="1" name="settings")
app-(id="2" name="people")
app-(id="3" name="calendar")
app-(id="4" name="treasury")
app-(id="5" name="politics")
app-(id="6" name="files")
app-(id="7" name="jobs")
app-(id="8" name="tasks")
app-(id="9" name="chat")
app-(id="10" name="announcements")
permission-(id="1" key="events.add" app-id="3" description="Can add events")
permission-(id="2" key="events.delete" app-id="3" description="Can delete events")
permission-(id="3" key="events.edit" app-id="3" description="Can edit events")
permission-(id="4" key="events.get" app-id="3" description="Can view events")
permission-(id="5" key="jobs.add" app-id="7" description="Can add jobs")
permission-(id="6" key="jobs.delete" app-id="7" description="Can delete jobs")
permission-(id="7" key="jobs.edit" app-id="7" description="Can edit jobs")
permission-(id="8" key="jobs.get" app-id="7" description="Can view jobs")
permission-(id="9" key="announcements.add" app-id="10" description="Can add announcements")
permission-(id="10" key="announcements.delete" app-id="10" description="Can delete announcements")
permission-(id="11" key="announcements.edit" app-id="10" description="Can edit announcements")
permission-(id="12" key="announcements.get" app-id="10" description="Can view announcements")
permission-(id="13" key="role_apps.edit" app-id="1" description="Can edit role apps")
permission-(id="14" key="roles.create" app-id="1" description="Can create roles")
permission-(id="15" key="roles.delete" app-id="1" description="Can delete roles")
permission-(id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications")
permission-(id="17" key="chats.create" app-id="9" description="Can create chats")
permission-(id="18" key="chats.edit" app-id="9" description="Can edit chats")
permission-(id="19" key="chats.delete" app-id="9" description="Can delete chats")
permission-(id="20" key="chats_message.send" app-id="9" description="Can send messages")
permission-(id="21" key="chats_message.edit" app-id="9" description="Can edit messages")
permission-(id="22" key="chats_message.delete" app-id="9" description="Can delete messages")
member-app-(id="1" member-id="1" app-id="1")
member-app-(id="2" member-id="4" app-id="1")
member-app-(id="3" member-id="6" app-id="1")
member-app-(id="4" member-id="1" app-id="2")
member-app-(id="5" member-id="4" app-id="2")
member-app-(id="6" member-id="6" app-id="2")
member-app-(id="7" member-id="1" app-id="3")
member-app-(id="8" member-id="4" app-id="3")
member-app-(id="9" member-id="6" app-id="3")
member-app-(id="10" member-id="1" app-id="4")
member-app-(id="11" member-id="4" app-id="4")
member-app-(id="12" member-id="6" app-id="4")
member-app-(id="13" member-id="1" app-id="5")
member-app-(id="14" member-id="4" app-id="5")
member-app-(id="15" member-id="6" app-id="5")
member-app-(id="16" member-id="1" app-id="6")
member-app-(id="17" member-id="4" app-id="6")
member-app-(id="18" member-id="6" app-id="6")
member-app-(id="19" member-id="1" app-id="7")
member-app-(id="20" member-id="4" app-id="7")
member-app-(id="21" member-id="6" app-id="7")
member-app-(id="22" member-id="1" app-id="8")
member-app-(id="23" member-id="4" app-id="8")
member-app-(id="24" member-id="6" app-id="8")
member-app-(id="25" member-id="1" app-id="9")
member-app-(id="26" member-id="4" app-id="10")
member-app-(id="27" member-id="6" app-id="9")
// network 1: Captured Sun
network-(id="1" name="Captured Sun" logo="cs.svg" abbreviation="cs" stripe-account-id="acct_1Sn6DwLpyskwAml9" created="2026-01-10 09:58:01.0074")
network-plan-(id="1" network-id="1" stripe-price-id="price_1T3uaxLpyskwAml9p0r0nh2h" name="Patron Membership" price="200.00" description="Members 40+" active="true" created="2026-03-29 22:14:45.414163")
network-plan-(id="2" network-id="1" stripe-price-id="price_1T3uaQLpyskwAml9rZAKBcy0" name="Regular Membership" price="100.00" description="Members 18-40" active="true" created="2026-03-29 22:14:45.414163")
join-code-(id="1" code="cs" network-id="1")
member-network-(id="1" member-id="1" network-id="1" created="2025-11-24 00:54:36.0784")
member-network-(id="2" member-id="2" network-id="1" created="2026-01-13 13:14:28.0178")
member-network-(id="3" member-id="3" network-id="1" created="2026-01-13 13:28:35.0701")
role-(id="1" network-id="1" name="admin" is-default="false")
role-(id="2" network-id="1" name="member" is-default="true")
role-app-(id="1" role-id="1" app-id="1" network-id="1")
role-app-(id="2" role-id="1" app-id="2" network-id="1")
role-app-(id="3" role-id="1" app-id="3" network-id="1")
role-app-(id="4" role-id="1" app-id="4" network-id="1")
role-app-(id="5" role-id="1" app-id="5" network-id="1")
role-app-(id="6" role-id="1" app-id="6" network-id="1")
role-app-(id="7" role-id="1" app-id="7" network-id="1")
role-app-(id="8" role-id="1" app-id="8" network-id="1")
role-app-(id="9" role-id="1" app-id="9" network-id="1")
role-app-(id="10" role-id="1" app-id="10" network-id="1")
role-permission-(id="1" role-id="1" permission-key="events.get" network-id="1")
role-permission-(id="2" role-id="1" permission-key="jobs.get" network-id="1")
role-permission-(id="3" role-id="1" permission-key="announcements.get" network-id="1")
role-permission-(id="4" role-id="1" permission-key="events.add" network-id="1")
role-permission-(id="5" role-id="1" permission-key="events.delete" network-id="1")
role-permission-(id="6" role-id="1" permission-key="events.edit" network-id="1")
role-permission-(id="7" role-id="1" permission-key="jobs.add" network-id="1")
role-permission-(id="8" role-id="1" permission-key="jobs.delete" network-id="1")
role-permission-(id="9" role-id="1" permission-key="jobs.edit" network-id="1")
role-permission-(id="10" role-id="1" permission-key="announcements.add" network-id="1")
role-permission-(id="11" role-id="1" permission-key="announcements.delete" network-id="1")
role-permission-(id="12" role-id="1" permission-key="announcements.edit" network-id="1")
role-permission-(id="13" role-id="1" permission-key="role_apps.edit" network-id="1")
role-permission-(id="14" role-id="1" permission-key="roles.create" network-id="1")
role-permission-(id="15" role-id="1" permission-key="roles.delete" network-id="1")
role-permission-(id="16" role-id="1" permission-key="chats.create" network-id="1")
role-permission-(id="17" role-id="1" permission-key="chats.edit" network-id="1")
role-permission-(id="18" role-id="1" permission-key="chats.delete" network-id="1")
role-permission-(id="19" role-id="1" permission-key="chats_message.send" network-id="1")
role-permission-(id="20" role-id="1" permission-key="chats_message.edit" network-id="1")
role-permission-(id="21" role-id="1" permission-key="chats_message.delete" network-id="1")
role-permission-(id="22" role-id="1" permission-key="role_notifications.edit" network-id="1")
role-permission-(id="23" role-id="2" permission-key="events.get" network-id="1")
role-permission-(id="24" role-id="2" permission-key="jobs.get" network-id="1")
role-permission-(id="25" role-id="2" permission-key="announcements.get" network-id="1")
role-permission-(id="26" role-id="2" permission-key="events.add" network-id="1")
role-permission-(id="27" role-id="2" permission-key="chats.create" network-id="1")
role-permission-(id="28" role-id="2" permission-key="chats_message.send" network-id="1")
member-role-(id="1" member-id="1" role-id="1" granted-by="1" network-id="1")
member-role-(id="2" member-id="2" role-id="2" granted-by="1" network-id="1")
member-role-(id="3" member-id="3" role-id="2" granted-by="1" network-id="1")
join-form-(schema="org_1" id="1" fname="James" lname="Mitchell" email="james.mitchell@gmail.com" phone="512-555-0101" county="Comal" time="2025-12-16 23:11:31.0011" network-id="1")
join-form-(schema="org_1" id="2" fname="Rachel" lname="Torres" email="rachel.torres@yahoo.com" phone="512-555-0102" county="Bexar" time="2025-12-19 19:23:12.0717" network-id="1")
join-form-(schema="org_1" id="3" fname="David" lname="Nguyen" email="david.nguyen@gmail.com" phone="830-555-0103" county="Comal" time="2026-01-06 16:55:29.0288" network-id="1")
join-form-(schema="org_1" id="4" fname="Emily" lname="Sanders" email="emily.sanders@outlook.com" phone="210-555-0104" county="Hays" time="2026-01-07 17:14:01.0711" network-id="1")
contact-form-(schema="org_1" id="1" fname="Marcus" lname="Webb" email="marcus.webb@gmail.com" phone="512-555-0201" county="Comal" message="Interested in volunteering at upcoming events." time="2025-12-29 13:20:28.0157" network-id="1")
contact-form-(schema="org_1" id="2" fname="Sandra" lname="Holloway" email="sandra.holloway@gmail.com" phone="830-555-0202" county="Comal" message="Would love to connect with your organization." time="2025-12-30 22:10:24.0971" network-id="1")
contact-form-(schema="org_1" id="3" fname="Robert" lname="Finley" email="robert.finley@gmail.com" phone="210-555-0203" county="Comal" message="Looking forward to getting more involved locally." time="2026-01-10 21:23:51.0073" network-id="1")
contact-form-(schema="org_1" id="4" fname="Barbara" lname="Crane" email="barbara.crane@outlook.com" phone="512-555-0204" county="Comal" message="Please reach out regarding the next meeting schedule." time="2026-01-10 21:23:54.0841" network-id="1")
// network 2: Hyperia
network-(id="2" name="Hyperia" logo="hyperia.svg" abbreviation="hyperia" stripe-account-id="acct_1S4w0GHZemeF9CKR" created="2026-01-10 09:58:01.0074")
join-code-(id="2" code="hyperia" network-id="2")
member-network-(id="4" member-id="1" network-id="2" created="2026-01-13 13:28:35.0701")
member-network-(id="5" member-id="4" network-id="2" created="2026-03-15 13:28:35.0701")
member-network-(id="6" member-id="6" network-id="2" created="2026-02-01 09:00:00+00")
member-network-(id="7" member-id="7" network-id="2" created="2026-02-01 09:00:00+00")
member-network-(id="8" member-id="8" network-id="2" created="2026-02-01 09:00:00+00")
member-network-(id="9" member-id="9" network-id="2" created="2026-02-01 09:00:00+00")
role-(id="3" network-id="2" name="admin" is-default="false")
role-(id="4" network-id="2" name="member" is-default="true")
role-app-(id="11" role-id="3" app-id="1" network-id="2")
role-app-(id="12" role-id="3" app-id="2" network-id="2")
role-app-(id="13" role-id="3" app-id="3" network-id="2")
role-app-(id="14" role-id="3" app-id="4" network-id="2")
role-app-(id="15" role-id="3" app-id="5" network-id="2")
role-app-(id="16" role-id="3" app-id="6" network-id="2")
role-app-(id="17" role-id="3" app-id="7" network-id="2")
role-app-(id="18" role-id="3" app-id="8" network-id="2")
role-app-(id="19" role-id="3" app-id="9" network-id="2")
role-app-(id="20" role-id="3" app-id="10" network-id="2")
role-permission-(id="29" role-id="3" permission-key="events.get" network-id="2")
role-permission-(id="30" role-id="3" permission-key="jobs.get" network-id="2")
role-permission-(id="31" role-id="3" permission-key="announcements.get" network-id="2")
role-permission-(id="32" role-id="3" permission-key="events.add" network-id="2")
role-permission-(id="33" role-id="3" permission-key="events.delete" network-id="2")
role-permission-(id="34" role-id="3" permission-key="events.edit" network-id="2")
role-permission-(id="35" role-id="3" permission-key="jobs.add" network-id="2")
role-permission-(id="36" role-id="3" permission-key="jobs.delete" network-id="2")
role-permission-(id="37" role-id="3" permission-key="jobs.edit" network-id="2")
role-permission-(id="38" role-id="3" permission-key="announcements.add" network-id="2")
role-permission-(id="39" role-id="3" permission-key="announcements.delete" network-id="2")
role-permission-(id="40" role-id="3" permission-key="announcements.edit" network-id="2")
role-permission-(id="41" role-id="3" permission-key="role_apps.edit" network-id="2")
role-permission-(id="42" role-id="3" permission-key="roles.create" network-id="2")
role-permission-(id="43" role-id="3" permission-key="roles.delete" network-id="2")
role-permission-(id="44" role-id="3" permission-key="chats.create" network-id="2")
role-permission-(id="45" role-id="3" permission-key="chats.edit" network-id="2")
role-permission-(id="46" role-id="3" permission-key="chats.delete" network-id="2")
role-permission-(id="47" role-id="3" permission-key="chats_message.send" network-id="2")
role-permission-(id="48" role-id="3" permission-key="chats_message.edit" network-id="2")
role-permission-(id="49" role-id="3" permission-key="chats_message.delete" network-id="2")
role-permission-(id="50" role-id="3" permission-key="role_notifications.edit" network-id="2")
role-permission-(id="51" role-id="4" permission-key="events.get" network-id="2")
role-permission-(id="52" role-id="4" permission-key="jobs.get" network-id="2")
role-permission-(id="53" role-id="4" permission-key="announcements.get" network-id="2")
role-permission-(id="54" role-id="4" permission-key="events.add" network-id="2")
role-permission-(id="55" role-id="4" permission-key="chats.create" network-id="2")
role-permission-(id="56" role-id="4" permission-key="chats_message.send" network-id="2")
member-role-(id="4" member-id="1" role-id="3" granted-by="1" network-id="2")
member-role-(id="5" member-id="4" role-id="3" granted-by="1" network-id="2")
member-role-(id="6" member-id="6" role-id="3" granted-by="1" network-id="2")
member-role-(id="7" member-id="6" role-id="4" granted-by="1" network-id="2")
member-role-(id="8" member-id="7" role-id="4" granted-by="1" network-id="2")
member-role-(id="9" member-id="8" role-id="4" granted-by="1" network-id="2")
member-role-(id="10" member-id="9" role-id="4" granted-by="1" network-id="2")
calendar-(schema="events" id="1" network-id="2" owner-id="1" name="Main Calendar" description="The main calendar for the network" color="#9E1C29")
calendar-(schema="events" id="2" network-id="2" owner-id="1" name="Sub-Calendar" description="Sub-calendar for the network" color="#3D6FAD")
calendar-(schema="events" id="3" network-id="2" owner-id="1" name="Sub-Calendar 2" description="Another sub-calendar for the network" color="#2A8636")
event-recurrence-(schema="events" id="1" frequency="weekly" interval="1" days-of-week="{2}" network-id="2")
event-recurrence-(schema="events" id="2" frequency="weekly" interval="2" days-of-week="{3}" count="10" network-id="2")
event-recurrence-(schema="events" id="3" frequency="weekly" interval="1" days-of-week="{1,3,5}" network-id="2")
event-(schema="events" id="1" network-id="2" creator-id="1" title="Client meeting" description="Meeting with big client for app deployment" time-start="2026-04-01T17:30:00.000Z" time-end="2026-04-01T19:00:00.000Z" location="Virtual" all-day="false" recurrence-id="2")
event-(schema="events" id="2" network-id="2" creator-id="1" title="Networking Event" description="Networking event for young professionals" time-start="2026-04-04T04:00:00.000Z" time-end="2026-04-06T04:00:00.000Z" location="GB Center" all-day="true")
event-(schema="events" id="3" network-id="2" creator-id="1" title="App deployment party" description="Come celebrate the app deployment!" time-start="2026-04-05T16:00:00.000Z" time-end="2026-04-05T21:30:00.000Z" location="Captured Sun HQ" all-day="false")
event-(schema="events" id="4" network-id="2" creator-id="4" title="Work on frontend changes" description="Reminder for work #1" time-start="2026-04-02T15:00:00.000Z" time-end="2026-04-02T19:00:00.000Z" all-day="false")
event-(schema="events" id="5" network-id="2" creator-id="4" title="Day off" description="I dont have to work today" time-start="2026-04-03T04:00:00.000Z" time-end="2026-04-03T04:00:00.000Z" all-day="true")
event-(schema="events" id="6" network-id="2" creator-id="4" title="Scrum Meeting - Main Team" description="Agile Week 32" time-start="2026-03-31T03:00:00.000Z" time-end="2026-03-31T06:15:00.000Z" location="Virtual" all-day="false" recurrence-id="1")
event-(schema="events" id="7" network-id="2" creator-id="1" title="Meeting with John Smiith" description="Lorem ipsum elorum" time-start="2026-03-31T18:30:00.000Z" time-end="2026-03-31T19:30:00.000Z" location="Virtual" all-day="false")
event-(schema="events" id="8" network-id="2" creator-id="1" title="Meeting with Jane Doe" description="Lorem ipsum elorum" time-start="2026-03-31T19:30:00.000Z" time-end="2026-03-31T19:45:00.000Z" location="Virtual" all-day="false")
event-(schema="events" id="9" network-id="2" creator-id="1" title="Meeting with president" description="Lorem ipsum elorum" time-start="2026-03-31T19:45:00.000Z" time-end="2026-03-31T21:15:00.000Z" location="Virtual" all-day="false")
event-(schema="events" id="10" network-id="2" creator-id="1" title="Meeting with investors" description="Lorem ipsum elorum" time-start="2026-03-31T21:30:00.000Z" time-end="2026-03-31T23:00:00.000Z" location="Virtual" all-day="false")
event-(schema="events" id="11" network-id="2" creator-id="1" title="Review Github changes" description="Review pushes from members to ensure all is well" time-start="2026-04-03T04:00:00.000Z" time-end="2026-04-03T04:00:00.000Z" all-day="true" recurrence-id="3")
event-calendar-(schema="events" id="1" event-id="1" calendar-id="1" network-id="2")
event-calendar-(schema="events" id="2" event-id="2" calendar-id="2" network-id="2")
event-calendar-(schema="events" id="3" event-id="3" calendar-id="1" network-id="2")
event-calendar-(schema="events" id="4" event-id="3" calendar-id="3" network-id="2")
event-calendar-(schema="events" id="5" event-id="4" calendar-id="2" network-id="2")
event-calendar-(schema="events" id="6" event-id="5" calendar-id="2" network-id="2")
event-calendar-(schema="events" id="7" event-id="6" calendar-id="1" network-id="2")
event-calendar-(schema="events" id="8" event-id="6" calendar-id="2" network-id="2")
event-calendar-(schema="events" id="9" event-id="7" calendar-id="3" network-id="2")
event-calendar-(schema="events" id="10" event-id="8" calendar-id="2" network-id="2")
event-calendar-(schema="events" id="11" event-id="8" calendar-id="3" network-id="2")
event-calendar-(schema="events" id="12" event-id="9" calendar-id="3" network-id="2")
event-calendar-(schema="events" id="13" event-id="10" calendar-id="3" network-id="2")
event-calendar-(schema="events" id="14" event-id="11" calendar-id="3" network-id="2")
job-(id="1" network-id="2" creator-id="1" title="Senior Frontend Engineer" company="Acme Corp" location="San Francisco, CA" employment-type="full-time" experience-level="senior" department="Engineering" salary-number="165000" salary-period="year" applicants="47" skills="React,TypeScript,CSS,GraphQL,Node.js" description="We&#x27;re looking for a Senior Frontend Engineer to join our growing product team. You&#x27;ll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences.&#10;&#10;You&#x27;ll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX." created="2026-04-29 10:00:00" updated-at="2026-04-29 10:00:00")
job-(id="2" network-id="2" creator-id="1" title="Product Designer" company="Blue River" location="New York, NY" employment-type="full-time" experience-level="mid" department="Design" salary-number="120000" salary-period="year" applicants="112" skills="Figma,Design Systems,Prototyping,User Research" description="Blue River is hiring a Product Designer to lead design across our core consumer product. You&#x27;ll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision.&#10;&#10;We&#x27;re a small, fast-moving team and designers have a huge impact here." created="2026-04-26 09:00:00" updated-at="2026-04-26 09:00:00")
job-(id="3" network-id="2" creator-id="1" title="Backend Engineer" company="Orbit Systems" location="Remote" employment-type="contract" experience-level="mid" department="Platform" salary-number="95" salary-period="hour" applicants="29" skills="Go,PostgreSQL,Kubernetes,gRPC,Redis" description="6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You&#x27;ll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability." created="2026-04-30 14:00:00" updated-at="2026-04-30 14:00:00")
job-(id="4" network-id="2" creator-id="1" title="Marketing Manager" company="Groundwork" location="Austin, TX" employment-type="full-time" experience-level="mid" department="Marketing" salary-number="98000" salary-period="year" applicants="88" skills="SEO,Content Strategy,Analytics,Paid Acquisition,Email Marketing" description="We need a Marketing Manager to own our top-of-funnel growth. You&#x27;ll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You&#x27;ll report directly to our Head of Growth." created="2026-04-21 11:00:00" updated-at="2026-04-21 11:00:00")
job-(id="5" network-id="2" creator-id="1" title="Data Analyst" company="Compass Data" location="Chicago, IL" employment-type="full-time" experience-level="entry" department="Analytics" salary-number="75000" salary-period="year" applicants="203" skills="SQL,Python,Tableau,dbt,Excel" description="A great entry-level opportunity for someone who loves data. You&#x27;ll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We&#x27;ll invest in your growth and give you plenty of mentorship." created="2026-04-28 08:30:00" updated-at="2026-04-28 08:30:00")
job-(id="6" network-id="2" creator-id="1" title="iOS Engineer" company="Fieldwork" location="Seattle, WA" employment-type="full-time" experience-level="senior" department="Mobile" salary-number="175000" salary-period="year" applicants="34" skills="Swift,SwiftUI,Combine,CoreData,Xcode" description="Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX." created="2026-04-24 13:00:00" updated-at="2026-04-24 13:00:00")
job-(id="7" network-id="2" creator-id="1" title="Operations Coordinator" company="Maple &amp; Co" location="Boston, MA" employment-type="part-time" experience-level="entry" department="Operations" salary-number="28" salary-period="hour" applicants="61" skills="Project Management,Excel,Communication,Scheduling" description="Part-time role (20 hrs/week) helping our operations team stay organized. You&#x27;ll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who&#x27;s highly organized and excited to grow into a full-time ops role." created="2026-04-17 10:00:00" updated-at="2026-04-17 10:00:00")
job-(id="8" network-id="2" creator-id="1" title="Machine Learning Intern" company="NeuralPath" location="Remote" employment-type="internship" experience-level="entry" department="AI Research" salary-number="8000" salary-period="month" applicants="394" skills="Python,PyTorch,Linear Algebra,Git" description="Summer internship (12 weeks) on our ML research team. You&#x27;ll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you&#x27;re proud of by the end of the summer. Strong preference for candidates who can start June 2." created="2026-05-01 09:00:00" updated-at="2026-05-01 09:00:00")
announcement-(id="1" network-id="2" creator-id="1" text="This is the first announcement" created="2026-01-13 15:37:00.0000" updated-at="2026-01-13 15:37:00.0000")
announcement-(id="2" network-id="2" creator-id="1" text="Here goes another announcement." created="2026-01-13 15:39:00.0000" updated-at="2026-01-13 15:39:00.0000")
announcement-(id="3" network-id="2" creator-id="4" text="My first announcement!" created="2026-01-14 15:41:00.0000" updated-at="2026-01-14 15:41:00.0000")
announcement-(id="4" network-id="2" creator-id="1" text="Testing announcement." created="2026-02-17 12:30:00.0000" updated-at="2026-02-17 12:30:00.0000")
announcement-(id="5" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:56:00.0000" updated-at="2026-03-23 15:56:00.0000")
announcement-(id="6" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:56:30.0000" updated-at="2026-03-23 15:56:30.0000")
announcement-(id="7" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:57:00.0000" updated-at="2026-03-23 15:57:00.0000")
announcement-(id="8" network-id="2" creator-id="1" text="Trying another user." created="2026-02-05 15:56:30.0000" updated-at="2026-02-05 15:56:30.0000")
announcement-(id="9" network-id="2" creator-id="1" text="One last announcement." created="2026-04-01 15:57:00.0000" updated-at="2026-01-13 15:57:00.0000")
chat-(schema="chats" id="1" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:42:00+00")
chat-(schema="chats" id="2" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 10:30:00+00")
chat-(schema="chats" id="3" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-04-30 18:00:00+00")
chat-(schema="chats" id="4" network-id="2" creator-id="4" type="group" name="Product Team" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:51:00+00")
chat-(schema="chats" id="5" network-id="2" creator-id="4" type="group" name="Design Review" created="2026-04-01 09:00:00+00" updated-at="2026-04-30 10:00:00+00")
chat-(schema="chats" id="6" network-id="2" creator-id="4" type="channel" name="general" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:55:00+00")
chat-(schema="chats" id="7" network-id="2" creator-id="4" type="channel" name="engineering" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00")
chat-(schema="chats" id="8" network-id="2" creator-id="8" type="announcement" name="Announcements" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 00:00:00+00")
chat-member-(schema="chats" id="1" chat-id="1" member-id="4" last-read-at="2026-05-01 07:12:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="2" chat-id="1" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="3" chat-id="2" member-id="4" last-read-at="2026-05-01 10:30:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="4" chat-id="2" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="5" chat-id="3" member-id="4" last-read-at="2026-04-30 18:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="6" chat-id="3" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="7" chat-id="4" member-id="4" last-read-at="2026-05-01 04:30:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="8" chat-id="4" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="9" chat-id="4" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="10" chat-id="4" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="11" chat-id="5" member-id="4" last-read-at="2026-04-30 10:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="12" chat-id="5" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="13" chat-id="5" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="14" chat-id="6" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="15" chat-id="6" member-id="4" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="16" chat-id="6" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="17" chat-id="6" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="18" chat-id="6" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="19" chat-id="6" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="20" chat-id="7" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="21" chat-id="7" member-id="4" last-read-at="2026-05-01 09:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="22" chat-id="7" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="23" chat-id="8" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="24" chat-id="8" member-id="4" last-read-at="2026-04-29 12:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="25" chat-id="8" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="26" chat-id="8" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="27" chat-id="8" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2")
chat-member-(schema="chats" id="28" chat-id="8" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2")
message-(schema="chats" id="1" chat-id="1" sender-id="4" text="Hey Sarah, did you see the new design mockups?" sent-at="2026-04-30 12:00:00+00" updated-at="2026-04-30 12:00:00+00" network-id="2")
message-(schema="chats" id="2" chat-id="1" sender-id="6" text="Just looked — they&#x27;re really clean. I love the new sidebar." sent-at="2026-04-30 12:30:00+00" updated-at="2026-04-30 12:30:00+00" network-id="2")
message-(schema="chats" id="3" chat-id="1" sender-id="4" text="Agreed. Alex did a great job." sent-at="2026-04-30 12:36:00+00" updated-at="2026-04-30 12:36:00+00" network-id="2")
message-(schema="chats" id="4" chat-id="1" sender-id="6" text="Are we going to ship this week or wait for the backend?" sent-at="2026-05-01 07:00:00+00" updated-at="2026-05-01 07:00:00+00" network-id="2")
message-(schema="chats" id="5" chat-id="1" sender-id="4" text="Let&#x27;s aim for Thursday. I&#x27;ll sync with Marcus." sent-at="2026-05-01 07:12:00+00" updated-at="2026-05-01 07:12:00+00" network-id="2")
message-(schema="chats" id="6" chat-id="1" sender-id="6" text="Sounds good 👍" sent-at="2026-05-01 07:18:00+00" updated-at="2026-05-01 07:18:00+00" network-id="2")
message-(schema="chats" id="7" chat-id="1" sender-id="6" text="Can you review the PR when you get a chance?" sent-at="2026-05-01 11:42:00+00" updated-at="2026-05-01 11:42:00+00" network-id="2")
message-(schema="chats" id="8" chat-id="2" sender-id="7" text="Hey, the API endpoint is returning 500s on staging." sent-at="2026-05-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00" network-id="2")
message-(schema="chats" id="9" chat-id="2" sender-id="4" text="Oh no — is it the auth middleware again?" sent-at="2026-05-01 09:06:00+00" updated-at="2026-05-01 09:06:00+00" network-id="2")
message-(schema="chats" id="10" chat-id="2" sender-id="7" text="Yep. Same issue as last week." sent-at="2026-05-01 09:12:00+00" updated-at="2026-05-01 09:12:00+00" network-id="2")
message-(schema="chats" id="11" chat-id="2" sender-id="4" text="I&#x27;ll patch it now. Give me 20 mins." sent-at="2026-05-01 09:15:00+00" updated-at="2026-05-01 09:15:00+00" network-id="2")
message-(schema="chats" id="12" chat-id="2" sender-id="7" text="Thanks, no rush." sent-at="2026-05-01 09:18:00+00" updated-at="2026-05-01 09:18:00+00" network-id="2")
message-(schema="chats" id="13" chat-id="2" sender-id="4" text="Fixed. Can you redeploy and check?" sent-at="2026-05-01 09:48:00+00" updated-at="2026-05-01 09:48:00+00" network-id="2")
message-(schema="chats" id="14" chat-id="2" sender-id="7" text="All green 🎉 Thanks!" sent-at="2026-05-01 09:54:00+00" updated-at="2026-05-01 09:54:00+00" network-id="2")
message-(schema="chats" id="15" chat-id="2" sender-id="4" text="I&#x27;ll send over the specs by EOD" sent-at="2026-05-01 10:30:00+00" updated-at="2026-05-01 10:30:00+00" network-id="2")
message-(schema="chats" id="16" chat-id="3" sender-id="8" text="Quick question — what&#x27;s the launch date for v2?" sent-at="2026-04-30 16:00:00+00" updated-at="2026-04-30 16:00:00+00" network-id="2")
message-(schema="chats" id="17" chat-id="3" sender-id="4" text="Still TBD, but we&#x27;re targeting end of May." sent-at="2026-04-30 16:12:00+00" updated-at="2026-04-30 16:12:00+00" network-id="2")
message-(schema="chats" id="18" chat-id="3" sender-id="8" text="Got it. I&#x27;ll update the roadmap doc." sent-at="2026-04-30 16:30:00+00" updated-at="2026-04-30 16:30:00+00" network-id="2")
message-(schema="chats" id="19" chat-id="3" sender-id="4" text="Perfect, thanks Priya." sent-at="2026-04-30 16:36:00+00" updated-at="2026-04-30 16:36:00+00" network-id="2")
message-(schema="chats" id="20" chat-id="3" sender-id="8" text="See you at the standup!" sent-at="2026-04-30 18:00:00+00" updated-at="2026-04-30 18:00:00+00" network-id="2")
message-(schema="chats" id="21" chat-id="4" sender-id="7" text="Morning everyone! API docs are updated." sent-at="2026-05-01 04:00:00+00" updated-at="2026-05-01 04:00:00+00" network-id="2")
message-(schema="chats" id="22" chat-id="4" sender-id="8" text="Nice work Marcus 🙌" sent-at="2026-05-01 04:06:00+00" updated-at="2026-05-01 04:06:00+00" network-id="2")
message-(schema="chats" id="23" chat-id="4" sender-id="4" text="I&#x27;ll start on the integration tests today." sent-at="2026-05-01 04:12:00+00" updated-at="2026-05-01 04:12:00+00" network-id="2")
message-(schema="chats" id="24" chat-id="4" sender-id="6" text="Great. I&#x27;m finishing up the onboarding screens." sent-at="2026-05-01 04:30:00+00" updated-at="2026-05-01 04:30:00+00" network-id="2")
message-(schema="chats" id="25" chat-id="4" sender-id="8" text="Can we do a quick sync at 2pm?" sent-at="2026-05-01 10:00:00+00" updated-at="2026-05-01 10:00:00+00" network-id="2")
message-(schema="chats" id="26" chat-id="4" sender-id="4" text="Works for me." sent-at="2026-05-01 10:03:00+00" updated-at="2026-05-01 10:03:00+00" network-id="2")
message-(schema="chats" id="27" chat-id="4" sender-id="7" text="Same" sent-at="2026-05-01 10:06:00+00" updated-at="2026-05-01 10:06:00+00" network-id="2")
message-(schema="chats" id="28" chat-id="4" sender-id="6" text="I&#x27;ll send the invite." sent-at="2026-05-01 10:09:00+00" updated-at="2026-05-01 10:09:00+00" network-id="2")
message-(schema="chats" id="29" chat-id="4" sender-id="6" text="I&#x27;ve updated the Figma file with the new flows" sent-at="2026-05-01 11:51:00+00" updated-at="2026-05-01 11:51:00+00" network-id="2")
message-(schema="chats" id="30" chat-id="5" sender-id="9" text="Hey, sharing the first round of designs for the settings page." sent-at="2026-04-30 06:00:00+00" updated-at="2026-04-30 06:00:00+00" network-id="2")
message-(schema="chats" id="31" chat-id="5" sender-id="6" text="These look great! Love the card layout." sent-at="2026-04-30 06:30:00+00" updated-at="2026-04-30 06:30:00+00" network-id="2")
message-(schema="chats" id="32" chat-id="5" sender-id="4" text="Agreed. One thought — the spacing on the form feels a bit tight." sent-at="2026-04-30 07:00:00+00" updated-at="2026-04-30 07:00:00+00" network-id="2")
message-(schema="chats" id="33" chat-id="5" sender-id="9" text="Good call. I&#x27;ll loosen it up." sent-at="2026-04-30 07:12:00+00" updated-at="2026-04-30 07:12:00+00" network-id="2")
message-(schema="chats" id="34" chat-id="5" sender-id="6" text="Also maybe we increase the font size slightly?" sent-at="2026-04-30 08:00:00+00" updated-at="2026-04-30 08:00:00+00" network-id="2")
message-(schema="chats" id="35" chat-id="5" sender-id="9" text="The contrast on mobile looks off — can we bump it?" sent-at="2026-04-30 10:00:00+00" updated-at="2026-04-30 10:00:00+00" network-id="2")
message-(schema="chats" id="36" chat-id="6" sender-id="8" text="Good morning team! Reminder: all-hands is Thursday at 10am." sent-at="2026-05-01 03:00:00+00" updated-at="2026-05-01 03:00:00+00" network-id="2")
message-(schema="chats" id="37" chat-id="6" sender-id="6" text="Thanks for the reminder!" sent-at="2026-05-01 03:06:00+00" updated-at="2026-05-01 03:06:00+00" network-id="2")
message-(schema="chats" id="38" chat-id="6" sender-id="9" text="Will there be a recording for those in other time zones?" sent-at="2026-05-01 03:18:00+00" updated-at="2026-05-01 03:18:00+00" network-id="2")
message-(schema="chats" id="39" chat-id="6" sender-id="8" text="Yes — I&#x27;ll post the link in #announcements after." sent-at="2026-05-01 03:24:00+00" updated-at="2026-05-01 03:24:00+00" network-id="2")
message-(schema="chats" id="40" chat-id="6" sender-id="4" text="Thanks Priya 🙏" sent-at="2026-05-01 03:30:00+00" updated-at="2026-05-01 03:30:00+00" network-id="2")
message-(schema="chats" id="41" chat-id="6" sender-id="7" text="Staging is back up btw, had a brief outage this morning." sent-at="2026-05-01 08:00:00+00" updated-at="2026-05-01 08:00:00+00" network-id="2")
message-(schema="chats" id="42" chat-id="6" sender-id="6" text="Oh I didn&#x27;t even notice, nice quick fix!" sent-at="2026-05-01 08:12:00+00" updated-at="2026-05-01 08:12:00+00" network-id="2")
message-(schema="chats" id="43" chat-id="6" sender-id="7" text="Just pushed the hotfix to production" sent-at="2026-05-01 11:55:00+00" updated-at="2026-05-01 11:55:00+00" network-id="2")
message-(schema="chats" id="44" chat-id="7" sender-id="7" text="Heads up: I&#x27;m updating the CI pipeline today. Builds might be slow for a bit." sent-at="2026-05-01 07:00:00+00" updated-at="2026-05-01 07:00:00+00" network-id="2")
message-(schema="chats" id="45" chat-id="7" sender-id="4" text="Noted, thanks for the warning." sent-at="2026-05-01 07:06:00+00" updated-at="2026-05-01 07:06:00+00" network-id="2")
message-(schema="chats" id="46" chat-id="7" sender-id="7" text="Back to normal now." sent-at="2026-05-01 08:00:00+00" updated-at="2026-05-01 08:00:00+00" network-id="2")
message-(schema="chats" id="47" chat-id="7" sender-id="4" text="PR is up: #247 — adds rate limiting to the auth routes" sent-at="2026-05-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00" network-id="2")
message-(schema="chats" id="48" chat-id="8" sender-id="8" text="Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer." sent-at="2026-04-28 12:00:00+00" updated-at="2026-04-28 12:00:00+00" network-id="2")
message-(schema="chats" id="49" chat-id="8" sender-id="8" text="Reminder: expense reports for March are due this Friday." sent-at="2026-04-29 12:00:00+00" updated-at="2026-04-29 12:00:00+00" network-id="2")
message-(schema="chats" id="50" chat-id="8" sender-id="8" text="Q2 planning kick-off is next Monday at 9am. Please come prepared with your team&#x27;s priorities." sent-at="2026-05-01 00:00:00+00" updated-at="2026-05-01 00:00:00+00" network-id="2")

336
priv/experiments/forms.sql Normal file
View File

@@ -0,0 +1,336 @@
-- members
INSERT INTO members (email, first_name, last_name, password, address1, address2, city, state, zipcode, country, county, phone, title, bio, image_path, notes, created, updated_at) VALUES
('samrussell99@pm.me', 'Sam', 'Russell', '$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'CEO', 'This is my bio', NULL, 'no notes', '2026-01-15 09:58:01.0072', '2026-01-15 09:58:01.0072'),
('freddyjkrueger@gmail.com', 'Freddy','Krueger', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'Villain', 'This is my bio', NULL, 'no notes', '2026-01-13 13:38:46.0810', '2026-01-13 13:38:46.0810'),
('harmysmarmy@gmail.com', 'Harmy','Smarmy', '$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'Associate', 'This is my bio', NULL, 'no notes', '2026-01-13 13:41:41.0722', '2026-01-13 13:41:41.0722'),
('matiascarulli@gmail.com', 'Matias', 'Carulli', '$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg', '1234 address NW 12th St', NULL, 'Miramar', 'Florida', '33029', 'US', 'Broward', '123-456-789', 'Developer', 'This is my bio', '/db/images/users/member-4/profile.png', 'no notes', '2026-03-15 13:41:41.0722', '2026-03-15 13:41:41.0722'),
('boulder@example.com', 'CU', 'Boulder', '$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E', '1234 address NW 12th St', NULL, 'Austin', 'Texas', '12345', 'US', 'Austin County', '123-456-789', 'Associate', 'This is my bio', NULL, 'no notes', '2026-03-26 01:03:18.803016+00', '2026-03-26 01:03:18.803016+00');
-- networks
INSERT INTO networks (name, logo, abbreviation, stripe_account_id, created) VALUES
('Captured Sun', 'cs.svg', 'cs', 'acct_1Sn6DwLpyskwAml9', '2026-01-10 09:58:01.0074'),
('Hyperia', 'hyperia.svg', 'hyperia', 'acct_1S4w0GHZemeF9CKR', '2026-01-10 09:58:01.0074');
-- apps
INSERT INTO apps (name) VALUES
('settings'),
('people'),
('calendar'),
('treasury'),
('politics'),
('files'),
('jobs'),
('tasks'),
('chat'),
('announcements');
INSERT INTO roles (network_id, name, is_default) VALUES
(1, 'admin', false),
(1, 'member', true),
(2, 'admin', false),
(2, 'member', true);
-- network_apps
INSERT INTO role_apps (role_id, app_id) VALUES
(1, 1),
(1, 2),
(1, 3),
(1, 4),
(1, 5),
(1, 6),
(1, 7),
(1, 8),
(1, 9),
(1, 10),
(3, 1),
(3, 2),
(3, 3),
(3, 4),
(3, 5),
(3, 6),
(3, 7),
(3, 8),
(3, 9),
(3, 10);
-- member_networks
INSERT INTO member_networks (member_id, network_id, created) VALUES
(1, 1, '2025-11-24 00:54:36.0784'),
(2, 1, '2026-01-13 13:14:28.0178'),
(3, 1, '2026-01-13 13:28:35.0701'),
(1, 2, '2026-01-13 13:28:35.0701'),
(4, 2, '2026-03-15 13:28:35.0701');
-- network_plans
INSERT INTO network_plans (id, network_id, stripe_price_id, name, price, description, active, created) VALUES
(1, 1, 'price_1T3uaxLpyskwAml9p0r0nh2h', 'Patron Membership', 200.00, 'Members 40+', true, '2026-03-29 22:14:45.414163'),
(2, 1, 'price_1T3uaQLpyskwAml9rZAKBcy0', 'Regular Membership', 100.00, 'Members 18-40', true, '2026-03-29 22:14:45.414163');
-- join form seed data
INSERT INTO org_1.join_form (fname, lname, email, phone, county, time) VALUES
('James', 'Mitchell', 'james.mitchell@gmail.com', '512-555-0101', 'Comal', '2025-12-16 23:11:31.0011'),
('Rachel', 'Torres', 'rachel.torres@yahoo.com', '512-555-0102', 'Bexar', '2025-12-19 19:23:12.0717'),
('David', 'Nguyen', 'david.nguyen@gmail.com', '830-555-0103', 'Comal', '2026-01-06 16:55:29.0288'),
('Emily', 'Sanders', 'emily.sanders@outlook.com', '210-555-0104', 'Hays', '2026-01-07 17:14:01.0711');
-- contact form seed data
INSERT INTO org_1.contact_form (fname, lname, email, phone, county, message, time) VALUES
('Marcus', 'Webb', 'marcus.webb@gmail.com', '512-555-0201', 'Comal', 'Interested in volunteering at upcoming events.', '2025-12-29 13:20:28.0157'),
('Sandra', 'Holloway', 'sandra.holloway@gmail.com', '830-555-0202', 'Comal', 'Would love to connect with your organization.', '2025-12-30 22:10:24.0971'),
('Robert', 'Finley', 'robert.finley@gmail.com', '210-555-0203', 'Comal', 'Looking forward to getting more involved locally.', '2026-01-10 21:23:51.0073'),
('Barbara', 'Crane', 'barbara.crane@outlook.com', '512-555-0204', 'Comal', 'Please reach out regarding the next meeting schedule.', '2026-01-10 21:23:54.0841');
-- calendars table seed data
INSERT INTO events.calendars (network_id, owner_id, name, description, color) VALUES
(2, 1, 'Main Calendar', 'The main calendar for the network', '#9E1C29'),
(2, 1, 'Sub-Calendar', 'Sub-calendar for the network', '#3D6FAD'),
(2, 1, 'Sub-Calendar 2', 'Another sub-calendar for the network', '#2A8636');
-- events table seed data
INSERT INTO events.events (network_id, creator_id, title, description, time_start, time_end, location, all_day) VALUES
(2, 1, 'Client meeting', 'Meeting with big client for app deployment', '2026-04-01T17:30:00.000Z', '2026-04-01T19:00:00.000Z', 'Virtual', false),
(2, 1, 'Networking Event', 'Networking event for young professionals', '2026-04-04T04:00:00.000Z', '2026-04-06T04:00:00.000Z', 'GB Center', true),
(2, 1, 'App deployment party', 'Come celebrate the app deployment!', '2026-04-05T16:00:00.000Z', '2026-04-05T21:30:00.000Z', 'Captured Sun HQ', false),
(2, 4, 'Work on frontend changes', 'Reminder for work #1', '2026-04-02T15:00:00.000Z', '2026-04-02T19:00:00.000Z', null, false),
(2, 4, 'Day off', 'I dont have to work today', '2026-04-03T04:00:00.000Z', '2026-04-03T04:00:00.000Z', null, true),
(2, 4, 'Scrum Meeting - Main Team', 'Agile Week 32', '2026-03-31T03:00:00.000Z', '2026-03-31T06:15:00.000Z', 'Virtual', false),
(2, 1, 'Meeting with John Smiith', 'Lorem ipsum elorum', '2026-03-31T18:30:00.000Z', '2026-03-31T19:30:00.000Z', 'Virtual', false),
(2, 1, 'Meeting with Jane Doe', 'Lorem ipsum elorum', '2026-03-31T19:30:00.000Z', '2026-03-31T19:45:00.000Z', 'Virtual', false),
(2, 1, 'Meeting with president', 'Lorem ipsum elorum', '2026-03-31T19:45:00.000Z', '2026-03-31T21:15:00.000Z', 'Virtual', false),
(2, 1, 'Meeting with investors', 'Lorem ipsum elorum', '2026-03-31T21:30:00.000Z', '2026-03-31T23:00:00.000Z', 'Virtual', false),
(2, 1, 'Review Github changes', 'Review pushes from members to ensure all is well', '2026-04-03T04:00:00.000Z', '2026-04-03T04:00:00.000Z', null, true);
INSERT INTO events.event_calendars (event_id, calendar_id) VALUES
(1, 1),
(2, 2),
(3, 1),
(3, 3),
(4, 2),
(5, 2),
(6, 1),
(6, 2),
(7, 3),
(8, 2),
(8, 3),
(9, 3),
(10, 3),
(11, 3);
-- jobs table seed data
INSERT INTO public.jobs (network_id, creator_id, title, company, location, employment_type, experience_level, department, salary_number, salary_period, applicants, skills, description, created, updated_at) VALUES
(2, 1, 'Senior Frontend Engineer', 'Acme Corp', 'San Francisco, CA', 'full-time', 'senior', 'Engineering', 165000, 'year', 47, ARRAY['React', 'TypeScript', 'CSS', 'GraphQL', 'Node.js'], E'We''re looking for a Senior Frontend Engineer to join our growing product team. You''ll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences.\n\nYou''ll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX.', '2026-04-29 10:00:00', '2026-04-29 10:00:00'),
(2, 1, 'Product Designer', 'Blue River', 'New York, NY', 'full-time', 'mid', 'Design', 120000, 'year', 112, ARRAY['Figma', 'Design Systems', 'Prototyping', 'User Research'], E'Blue River is hiring a Product Designer to lead design across our core consumer product. You''ll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision.\n\nWe''re a small, fast-moving team and designers have a huge impact here.', '2026-04-26 09:00:00', '2026-04-26 09:00:00'),
(2, 1, 'Backend Engineer', 'Orbit Systems', 'Remote', 'contract', 'mid', 'Platform', 95, 'hour', 29, ARRAY['Go', 'PostgreSQL', 'Kubernetes', 'gRPC', 'Redis'], '6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You''ll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability.', '2026-04-30 14:00:00', '2026-04-30 14:00:00'),
(2, 1, 'Marketing Manager', 'Groundwork', 'Austin, TX', 'full-time', 'mid', 'Marketing', 98000, 'year', 88, ARRAY['SEO', 'Content Strategy', 'Analytics', 'Paid Acquisition', 'Email Marketing'], E'We need a Marketing Manager to own our top-of-funnel growth. You''ll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You''ll report directly to our Head of Growth.', '2026-04-21 11:00:00', '2026-04-21 11:00:00'),
(2, 1, 'Data Analyst', 'Compass Data', 'Chicago, IL', 'full-time', 'entry', 'Analytics', 75000, 'year', 203, ARRAY['SQL', 'Python', 'Tableau', 'dbt', 'Excel'], 'A great entry-level opportunity for someone who loves data. You''ll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We''ll invest in your growth and give you plenty of mentorship.', '2026-04-28 08:30:00', '2026-04-28 08:30:00'),
(2, 1, 'iOS Engineer', 'Fieldwork', 'Seattle, WA', 'full-time', 'senior', 'Mobile', 175000, 'year', 34, ARRAY['Swift', 'SwiftUI', 'Combine', 'CoreData', 'Xcode'], E'Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX.', '2026-04-24 13:00:00', '2026-04-24 13:00:00'),
(2, 1, 'Operations Coordinator', 'Maple & Co', 'Boston, MA', 'part-time', 'entry', 'Operations', 28, 'hour', 61, ARRAY['Project Management', 'Excel', 'Communication', 'Scheduling'], E'Part-time role (20 hrs/week) helping our operations team stay organized. You''ll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who''s highly organized and excited to grow into a full-time ops role.', '2026-04-17 10:00:00', '2026-04-17 10:00:00'),
(2, 1, 'Machine Learning Intern', 'NeuralPath', 'Remote', 'internship', 'entry', 'AI Research', 8000, 'month', 394, ARRAY['Python', 'PyTorch', 'Linear Algebra', 'Git'], 'Summer internship (12 weeks) on our ML research team. You''ll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you''re proud of by the end of the summer. Strong preference for candidates who can start June 2.', '2026-05-01 09:00:00', '2026-05-01 09:00:00');
INSERT INTO permissions (key, app_id, description) VALUES
('events.add', 3, 'Can add events'),
('events.delete', 3, 'Can delete events'),
('events.edit', 3, 'Can edit events'),
('events.get', 3, 'Can view events'),
('jobs.add', 7, 'Can add jobs'),
('jobs.delete', 7, 'Can delete jobs'),
('jobs.edit', 7, 'Can edit jobs'),
('jobs.get', 7, 'Can view jobs'),
('announcements.add', 10, 'Can add announcements'),
('announcements.delete', 10, 'Can delete announcements'),
('announcements.edit', 10, 'Can edit announcements'),
('announcements.get', 10, 'Can view announcements'),
('role_apps.edit', 1, 'Can edit role apps'),
('roles.create', 1, 'Can create roles'),
('roles.delete', 1, 'Can delete roles'),
('role_notifications.edit', 1, 'Can edit role notifications'),
('chats.create', 9, 'Can create chats'),
('chats.edit', 9, 'Can edit chats'),
('chats.delete', 9, 'Can delete chats'),
('chats_message.send', 9, 'Can send messages'),
('chats_message.edit', 9, 'Can edit messages'),
('chats_message.delete', 9, 'Can delete messages');
INSERT INTO role_permissions (role_id, permission_key) VALUES
(1, 'events.get'), (1, 'jobs.get'), (1, 'announcements.get'), (1, 'events.add'), (1, 'events.delete'), (1, 'events.edit'), (1, 'jobs.add'), (1, 'jobs.delete'), (1, 'jobs.edit'), (1, 'announcements.add'), (1, 'announcements.delete'), (1, 'announcements.edit'), (1, 'role_apps.edit'), (1, 'roles.create'), (1, 'roles.delete'), (1, 'chats.create'), (1, 'chats.edit'), (1, 'chats.delete'), (1, 'chats_message.send'), (1, 'chats_message.edit'), (1, 'chats_message.delete'), (1, 'role_notifications.edit'),-- network 1 admin
(2, 'events.get'), (2, 'jobs.get'), (2, 'announcements.get'), (2, 'events.add'), (2, 'chats.create'), (2, 'chats_message.send'), -- network 1 user
(3, 'events.get'), (3, 'jobs.get'), (3, 'announcements.get'), (3, 'events.add'), (3, 'events.delete'), (3, 'events.edit'), (3, 'jobs.add'), (3, 'jobs.delete'), (3, 'jobs.edit'), (3, 'announcements.add'), (3, 'announcements.delete'), (3, 'announcements.edit'), (3, 'role_apps.edit'), (3, 'roles.create'), (3, 'roles.delete'), (3, 'chats.create'), (3, 'chats.edit'), (3, 'chats.delete'), (3, 'chats_message.send'), (3, 'chats_message.edit'), (3, 'chats_message.delete'), (3, 'role_notifications.edit'),-- network 2 admin
(4, 'events.get'), (4, 'jobs.get'), (4, 'announcements.get'), (4, 'events.add'), (4, 'chats.create'), (4, 'chats_message.send'); -- network 2 user
INSERT INTO member_roles (member_id, role_id, granted_by) VALUES
(1, 1, 1), (2, 2, 1), (3, 2, 1), -- network 1
(1, 3, 1), (4, 3, 1), (6, 3, 1); -- network 2
INSERT INTO announcements (network_id, creator_id, text, created, updated_at) VALUES
(2, 1, 'This is the first announcement', '2026-01-13 15:37:00.0000', '2026-01-13 15:37:00.0000'),
(2, 1, 'Here goes another announcement.', '2026-01-13 15:39:00.0000', '2026-01-13 15:39:00.0000'),
(2, 4, 'My first announcement!', '2026-01-14 15:41:00.0000', '2026-01-14 15:41:00.0000'),
(2, 1, 'Testing announcement.', '2026-02-17 12:30:00.0000', '2026-02-17 12:30:00.0000'),
(2, 4, 'Quick fire!', '2026-03-23 15:56:00.0000', '2026-03-23 15:56:00.0000'),
(2, 4, 'Quick fire!', '2026-03-23 15:56:30.0000', '2026-03-23 15:56:30.0000'),
(2, 4, 'Quick fire!', '2026-03-23 15:57:00.0000', '2026-03-23 15:57:00.0000'),
(2, 1, 'Trying another user.', '2026-02-05 15:56:30.0000', '2026-02-05 15:56:30.0000'),
(2, 1, 'One last announcement.', '2026-04-01 15:57:00.0000', '2026-01-13 15:57:00.0000');
INSERT INTO public.join_code (code, network_id) VALUES
('cs', 1),
('hyperia', 2);
-- Recurrence rules for 3 seed events (0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat)
INSERT INTO events.event_recurrence (frequency, interval, days_of_week, end_date, count) VALUES
('weekly', 1, '{2}', null, null), -- id=1: every Tuesday → Scrum Meeting
('weekly', 2, '{3}', null, 10 ), -- id=2: every 2nd Wednesday x10 → Client Meeting
('weekly', 1, '{1,3,5}', null, null); -- id=3: every Mon/Wed/Fri → Review Github
UPDATE events.events SET recurrence_id = 1 WHERE id = 6;
UPDATE events.events SET recurrence_id = 2 WHERE id = 1;
UPDATE events.events SET recurrence_id = 3 WHERE id = 11;
ALTER TABLE public.files ALTER COLUMN type TYPE varchar(100);
-- chat seed members (Sarah McIntyre=6, Marcus Webb=7, Priya Anand=8, Jordan Kim=9)
INSERT INTO members (email, first_name, last_name, password, address1, address2, city, state, zipcode, country, county, phone, title, bio, image_path, notes, created, updated_at) VALUES
('sarah.mcintyre@example.com', 'Sarah', 'McIntyre', '$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'Designer', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'),
('marcus.webb@example.com', 'Marcus', 'Webb', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'Engineer', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'),
('priya.anand@example.com', 'Priya', 'Anand', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'PM', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00'),
('jordan.kim@example.com', 'Jordan', 'Kim', '$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM', '1234 Address St', NULL, 'Austin', 'Texas', '12345', 'US', 'Travis', '123-456-789', 'Designer', 'This is my bio', NULL, NULL, '2026-02-01 09:00:00+00', '2026-02-01 09:00:00+00');
INSERT INTO member_networks (member_id, network_id, created) VALUES
(6, 2, '2026-02-01 09:00:00+00'),
(7, 2, '2026-02-01 09:00:00+00'),
(8, 2, '2026-02-01 09:00:00+00'),
(9, 2, '2026-02-01 09:00:00+00');
INSERT INTO member_roles (member_id, role_id, granted_by) VALUES
(6, 4, 1),
(7, 4, 1),
(8, 4, 1),
(9, 4, 1);
-- chats (network 2 / Hyperia)
-- updated_at reflects the last message's sent_at for each chat
INSERT INTO chats.chats (network_id, creator_id, type, name, created, updated_at) VALUES
(2, 4, 'dm', NULL, '2026-04-01 09:00:00+00', '2026-05-01 11:42:00+00'), -- 1: DM Matias↔Sarah
(2, 4, 'dm', NULL, '2026-04-01 09:00:00+00', '2026-05-01 10:30:00+00'), -- 2: DM Matias↔Marcus
(2, 4, 'dm', NULL, '2026-04-01 09:00:00+00', '2026-04-30 18:00:00+00'), -- 3: DM Matias↔Priya
(2, 4, 'group', 'Product Team', '2026-04-01 09:00:00+00', '2026-05-01 11:51:00+00'), -- 4
(2, 4, 'group', 'Design Review', '2026-04-01 09:00:00+00', '2026-04-30 10:00:00+00'), -- 5
(2, 4, 'channel', 'general', '2026-04-01 09:00:00+00', '2026-05-01 11:55:00+00'), -- 6
(2, 4, 'channel', 'engineering', '2026-04-01 09:00:00+00', '2026-05-01 09:00:00+00'), -- 7
(2, 8, 'announcement', 'Announcements', '2026-04-01 09:00:00+00', '2026-05-01 00:00:00+00'); -- 8
-- chat_members
-- last_read_at for member 4 (Matias) is set to match the unread counts from the hardcoded data
INSERT INTO chats.chat_members (chat_id, member_id, last_read_at, joined_at) VALUES
-- chat 1: DM Sarah (unread=2 → Matias last read before the final 2 messages from Sarah)
(1, 4, '2026-05-01 07:12:00+00', '2026-04-01 09:00:00+00'),
(1, 6, NULL, '2026-04-01 09:00:00+00'),
-- chat 2: DM Marcus (unread=0)
(2, 4, '2026-05-01 10:30:00+00', '2026-04-01 09:00:00+00'),
(2, 7, NULL, '2026-04-01 09:00:00+00'),
-- chat 3: DM Priya (unread=0)
(3, 4, '2026-04-30 18:00:00+00', '2026-04-01 09:00:00+00'),
(3, 8, NULL, '2026-04-01 09:00:00+00'),
-- chat 4: Product Team (unread=5 → Matias last read after the 4th message)
(4, 4, '2026-05-01 04:30:00+00', '2026-04-01 09:00:00+00'),
(4, 6, NULL, '2026-04-01 09:00:00+00'),
(4, 7, NULL, '2026-04-01 09:00:00+00'),
(4, 8, NULL, '2026-04-01 09:00:00+00'),
-- chat 5: Design Review (unread=0)
(5, 4, '2026-04-30 10:00:00+00', '2026-04-01 09:00:00+00'),
(5, 6, NULL, '2026-04-01 09:00:00+00'),
(5, 9, NULL, '2026-04-01 09:00:00+00'),
-- chat 6: #general (unread=11 → all unread, NULL)
(6, 1, NULL, '2026-04-01 09:00:00+00'),
(6, 4, NULL, '2026-04-01 09:00:00+00'),
(6, 6, NULL, '2026-04-01 09:00:00+00'),
(6, 7, NULL, '2026-04-01 09:00:00+00'),
(6, 8, NULL, '2026-04-01 09:00:00+00'),
(6, 9, NULL, '2026-04-01 09:00:00+00'),
-- chat 7: #engineering (unread=0)
(7, 1, NULL, '2026-04-01 09:00:00+00'),
(7, 4, '2026-05-01 09:00:00+00', '2026-04-01 09:00:00+00'),
(7, 7, NULL, '2026-04-01 09:00:00+00'),
-- chat 8: Announcements (unread=1 → Matias last read after the 2nd message)
(8, 1, NULL, '2026-04-01 09:00:00+00'),
(8, 4, '2026-04-29 12:00:00+00', '2026-04-01 09:00:00+00'),
(8, 6, NULL, '2026-04-01 09:00:00+00'),
(8, 7, NULL, '2026-04-01 09:00:00+00'),
(8, 8, NULL, '2026-04-01 09:00:00+00'),
(8, 9, NULL, '2026-04-01 09:00:00+00');
-- messages
-- Timestamps computed from "now" = 2026-05-01 12:00:00+00, matching ago(h) from the hardcoded constructor
-- sender_id mapping: Matias=4, Sarah=6, Marcus=7, Priya=8, Jordan=9
INSERT INTO chats.messages (chat_id, sender_id, text, sent_at, updated_at) VALUES
-- chat 1: DM Matias↔Sarah
(1, 4, 'Hey Sarah, did you see the new design mockups?', '2026-04-30 12:00:00+00', '2026-04-30 12:00:00+00'),
(1, 6, 'Just looked — they''re really clean. I love the new sidebar.','2026-04-30 12:30:00+00', '2026-04-30 12:30:00+00'),
(1, 4, 'Agreed. Alex did a great job.', '2026-04-30 12:36:00+00', '2026-04-30 12:36:00+00'),
(1, 6, 'Are we going to ship this week or wait for the backend?', '2026-05-01 07:00:00+00', '2026-05-01 07:00:00+00'),
(1, 4, 'Let''s aim for Thursday. I''ll sync with Marcus.', '2026-05-01 07:12:00+00', '2026-05-01 07:12:00+00'),
(1, 6, 'Sounds good 👍', '2026-05-01 07:18:00+00', '2026-05-01 07:18:00+00'),
(1, 6, 'Can you review the PR when you get a chance?', '2026-05-01 11:42:00+00', '2026-05-01 11:42:00+00'),
-- chat 2: DM Matias↔Marcus
(2, 7, 'Hey, the API endpoint is returning 500s on staging.', '2026-05-01 09:00:00+00', '2026-05-01 09:00:00+00'),
(2, 4, 'Oh no — is it the auth middleware again?', '2026-05-01 09:06:00+00', '2026-05-01 09:06:00+00'),
(2, 7, 'Yep. Same issue as last week.', '2026-05-01 09:12:00+00', '2026-05-01 09:12:00+00'),
(2, 4, 'I''ll patch it now. Give me 20 mins.', '2026-05-01 09:15:00+00', '2026-05-01 09:15:00+00'),
(2, 7, 'Thanks, no rush.', '2026-05-01 09:18:00+00', '2026-05-01 09:18:00+00'),
(2, 4, 'Fixed. Can you redeploy and check?', '2026-05-01 09:48:00+00', '2026-05-01 09:48:00+00'),
(2, 7, 'All green 🎉 Thanks!', '2026-05-01 09:54:00+00', '2026-05-01 09:54:00+00'),
(2, 4, 'I''ll send over the specs by EOD', '2026-05-01 10:30:00+00', '2026-05-01 10:30:00+00'),
-- chat 3: DM Matias↔Priya
(3, 8, 'Quick question — what''s the launch date for v2?', '2026-04-30 16:00:00+00', '2026-04-30 16:00:00+00'),
(3, 4, 'Still TBD, but we''re targeting end of May.', '2026-04-30 16:12:00+00', '2026-04-30 16:12:00+00'),
(3, 8, 'Got it. I''ll update the roadmap doc.', '2026-04-30 16:30:00+00', '2026-04-30 16:30:00+00'),
(3, 4, 'Perfect, thanks Priya.', '2026-04-30 16:36:00+00', '2026-04-30 16:36:00+00'),
(3, 8, 'See you at the standup!', '2026-04-30 18:00:00+00', '2026-04-30 18:00:00+00'),
-- chat 4: Product Team
(4, 7, 'Morning everyone! API docs are updated.', '2026-05-01 04:00:00+00', '2026-05-01 04:00:00+00'),
(4, 8, 'Nice work Marcus 🙌', '2026-05-01 04:06:00+00', '2026-05-01 04:06:00+00'),
(4, 4, 'I''ll start on the integration tests today.', '2026-05-01 04:12:00+00', '2026-05-01 04:12:00+00'),
(4, 6, 'Great. I''m finishing up the onboarding screens.', '2026-05-01 04:30:00+00', '2026-05-01 04:30:00+00'),
(4, 8, 'Can we do a quick sync at 2pm?', '2026-05-01 10:00:00+00', '2026-05-01 10:00:00+00'),
(4, 4, 'Works for me.', '2026-05-01 10:03:00+00', '2026-05-01 10:03:00+00'),
(4, 7, 'Same', '2026-05-01 10:06:00+00', '2026-05-01 10:06:00+00'),
(4, 6, 'I''ll send the invite.', '2026-05-01 10:09:00+00', '2026-05-01 10:09:00+00'),
(4, 6, 'I''ve updated the Figma file with the new flows', '2026-05-01 11:51:00+00', '2026-05-01 11:51:00+00'),
-- chat 5: Design Review
(5, 9, 'Hey, sharing the first round of designs for the settings page.', '2026-04-30 06:00:00+00', '2026-04-30 06:00:00+00'),
(5, 6, 'These look great! Love the card layout.', '2026-04-30 06:30:00+00', '2026-04-30 06:30:00+00'),
(5, 4, 'Agreed. One thought — the spacing on the form feels a bit tight.', '2026-04-30 07:00:00+00', '2026-04-30 07:00:00+00'),
(5, 9, 'Good call. I''ll loosen it up.', '2026-04-30 07:12:00+00', '2026-04-30 07:12:00+00'),
(5, 6, 'Also maybe we increase the font size slightly?', '2026-04-30 08:00:00+00', '2026-04-30 08:00:00+00'),
(5, 9, 'The contrast on mobile looks off — can we bump it?', '2026-04-30 10:00:00+00', '2026-04-30 10:00:00+00'),
-- chat 6: #general
(6, 8, 'Good morning team! Reminder: all-hands is Thursday at 10am.', '2026-05-01 03:00:00+00', '2026-05-01 03:00:00+00'),
(6, 6, 'Thanks for the reminder!', '2026-05-01 03:06:00+00', '2026-05-01 03:06:00+00'),
(6, 9, 'Will there be a recording for those in other time zones?', '2026-05-01 03:18:00+00', '2026-05-01 03:18:00+00'),
(6, 8, 'Yes — I''ll post the link in #announcements after.', '2026-05-01 03:24:00+00', '2026-05-01 03:24:00+00'),
(6, 4, 'Thanks Priya 🙏', '2026-05-01 03:30:00+00', '2026-05-01 03:30:00+00'),
(6, 7, 'Staging is back up btw, had a brief outage this morning.', '2026-05-01 08:00:00+00', '2026-05-01 08:00:00+00'),
(6, 6, 'Oh I didn''t even notice, nice quick fix!', '2026-05-01 08:12:00+00', '2026-05-01 08:12:00+00'),
(6, 7, 'Just pushed the hotfix to production', '2026-05-01 11:55:00+00', '2026-05-01 11:55:00+00'),
-- chat 7: #engineering
(7, 7, 'Heads up: I''m updating the CI pipeline today. Builds might be slow for a bit.', '2026-05-01 07:00:00+00', '2026-05-01 07:00:00+00'),
(7, 4, 'Noted, thanks for the warning.', '2026-05-01 07:06:00+00', '2026-05-01 07:06:00+00'),
(7, 7, 'Back to normal now.', '2026-05-01 08:00:00+00', '2026-05-01 08:00:00+00'),
(7, 4, 'PR is up: #247 — adds rate limiting to the auth routes', '2026-05-01 09:00:00+00', '2026-05-01 09:00:00+00'),
-- chat 8: Announcements
(8, 8, 'Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer.', '2026-04-28 12:00:00+00', '2026-04-28 12:00:00+00'),
(8, 8, 'Reminder: expense reports for March are due this Friday.', '2026-04-29 12:00:00+00', '2026-04-29 12:00:00+00'),
(8, 8, 'Q2 planning kick-off is next Monday at 9am. Please come prepared with your team''s priorities.', '2026-05-01 00:00:00+00', '2026-05-01 00:00:00+00');
-- member_apps
INSERT INTO member_apps (member_id, app_id) VALUES
(1, 1), (4, 1), (6, 1),
(1, 2), (4, 2), (6, 2),
(1, 3), (4, 3), (6, 3),
(1, 4), (4, 4), (6, 4),
(1, 5), (4, 5), (6, 5),
(1, 6), (4, 6), (6, 6),
(1, 7), (4, 7), (6, 7),
(1, 8), (4, 8), (6, 8),
(1, 9), (4, 10), (6, 9);

72
priv/forms.html Normal file
View File

@@ -0,0 +1,72 @@
<forms->
<!-- global -->
<member- id="1" email="samrussell99@pm.me" first-name="Sam" last-name="Russell" password="$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="CEO" bio="This is my bio" notes="no notes" created="2026-01-15 09:58:01.0072" updated-at="2026-01-15 09:58:01.0072"></member->
<member- id="2" email="freddyjkrueger@gmail.com" first-name="Freddy" last-name="Krueger" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Villain" bio="This is my bio" notes="no notes" created="2026-01-13 13:38:46.0810" updated-at="2026-01-13 13:38:46.0810"></member->
<member- id="3" email="harmysmarmy@gmail.com" first-name="Harmy" last-name="Smarmy" password="$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-01-13 13:41:41.0722" updated-at="2026-01-13 13:41:41.0722"></member->
<member- id="4" email="matiascarulli@gmail.com" first-name="Matias" last-name="Carulli" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 address NW 12th St" city="Miramar" state="Florida" zipcode="33029" country="US" county="Broward" phone="123-456-789" title="Developer" bio="This is my bio" image-path="/db/images/users/member-4/profile.png" notes="no notes" created="2026-03-15 13:41:41.0722" updated-at="2026-03-15 13:41:41.0722"></member->
<member- id="5" email="boulder@example.com" first-name="CU" last-name="Boulder" password="$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-03-26 01:03:18.803016+00" updated-at="2026-03-26 01:03:18.803016+00"></member->
<member- id="6" email="sarah.mcintyre@example.com" first-name="Sarah" last-name="McIntyre" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="7" email="marcus.webb@example.com" first-name="Marcus" last-name="Webb" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Engineer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="8" email="priya.anand@example.com" first-name="Priya" last-name="Anand" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="PM" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="9" email="jordan.kim@example.com" first-name="Jordan" last-name="Kim" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<app- id="1" name="settings"></app->
<app- id="2" name="people"></app->
<app- id="3" name="calendar"></app->
<app- id="4" name="treasury"></app->
<app- id="5" name="politics"></app->
<app- id="6" name="files"></app->
<app- id="7" name="jobs"></app->
<app- id="8" name="tasks"></app->
<app- id="9" name="chat"></app->
<app- id="10" name="announcements"></app->
<permission- id="1" key="events.add" app-id="3" description="Can add events"></permission->
<permission- id="2" key="events.delete" app-id="3" description="Can delete events"></permission->
<permission- id="3" key="events.edit" app-id="3" description="Can edit events"></permission->
<permission- id="4" key="events.get" app-id="3" description="Can view events"></permission->
<permission- id="5" key="jobs.add" app-id="7" description="Can add jobs"></permission->
<permission- id="6" key="jobs.delete" app-id="7" description="Can delete jobs"></permission->
<permission- id="7" key="jobs.edit" app-id="7" description="Can edit jobs"></permission->
<permission- id="8" key="jobs.get" app-id="7" description="Can view jobs"></permission->
<permission- id="9" key="announcements.add" app-id="10" description="Can add announcements"></permission->
<permission- id="10" key="announcements.delete" app-id="10" description="Can delete announcements"></permission->
<permission- id="11" key="announcements.edit" app-id="10" description="Can edit announcements"></permission->
<permission- id="12" key="announcements.get" app-id="10" description="Can view announcements"></permission->
<permission- id="13" key="role_apps.edit" app-id="1" description="Can edit role apps"></permission->
<permission- id="14" key="roles.create" app-id="1" description="Can create roles"></permission->
<permission- id="15" key="roles.delete" app-id="1" description="Can delete roles"></permission->
<permission- id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications"></permission->
<permission- id="17" key="chats.create" app-id="9" description="Can create chats"></permission->
<permission- id="18" key="chats.edit" app-id="9" description="Can edit chats"></permission->
<permission- id="19" key="chats.delete" app-id="9" description="Can delete chats"></permission->
<permission- id="20" key="chats_message.send" app-id="9" description="Can send messages"></permission->
<permission- id="21" key="chats_message.edit" app-id="9" description="Can edit messages"></permission->
<permission- id="22" key="chats_message.delete" app-id="9" description="Can delete messages"></permission->
<member-app- id="1" member-id="1" app-id="1"></member-app->
<member-app- id="2" member-id="4" app-id="1"></member-app->
<member-app- id="3" member-id="6" app-id="1"></member-app->
<member-app- id="4" member-id="1" app-id="2"></member-app->
<member-app- id="5" member-id="4" app-id="2"></member-app->
<member-app- id="6" member-id="6" app-id="2"></member-app->
<member-app- id="7" member-id="1" app-id="3"></member-app->
<member-app- id="8" member-id="4" app-id="3"></member-app->
<member-app- id="9" member-id="6" app-id="3"></member-app->
<member-app- id="10" member-id="1" app-id="4"></member-app->
<member-app- id="11" member-id="4" app-id="4"></member-app->
<member-app- id="12" member-id="6" app-id="4"></member-app->
<member-app- id="13" member-id="1" app-id="5"></member-app->
<member-app- id="14" member-id="4" app-id="5"></member-app->
<member-app- id="15" member-id="6" app-id="5"></member-app->
<member-app- id="16" member-id="1" app-id="6"></member-app->
<member-app- id="17" member-id="4" app-id="6"></member-app->
<member-app- id="18" member-id="6" app-id="6"></member-app->
<member-app- id="19" member-id="1" app-id="7"></member-app->
<member-app- id="20" member-id="4" app-id="7"></member-app->
<member-app- id="21" member-id="6" app-id="7"></member-app->
<member-app- id="22" member-id="1" app-id="8"></member-app->
<member-app- id="23" member-id="4" app-id="8"></member-app->
<member-app- id="24" member-id="6" app-id="8"></member-app->
<member-app- id="25" member-id="1" app-id="9"></member-app->
<member-app- id="26" member-id="4" app-id="10"></member-app->
<member-app- id="27" member-id="6" app-id="9"></member-app->
</forms->

15
priv/js/form_init.js Normal file
View File

@@ -0,0 +1,15 @@
// Generic init script for a supervised form runtime. The supervisor
// (Forum.Forms) injects two globals via QuickBEAM's :define
// option before this script runs:
//
// className: string - e.g. "Task"
// formData: object - attributes + content from the source element
//
// We build a class with the requested name that extends Form, then
// instantiate it. The instance lives for the life of this runtime.
class Form {
constructor(data) {
Object.assign(this, data);
}
}

View File

@@ -0,0 +1,264 @@
<forms->
<!-- global -->
<member- id="1" email="samrussell99@pm.me" first-name="Sam" last-name="Russell" password="$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="CEO" bio="This is my bio" notes="no notes" created="2026-01-15 09:58:01.0072" updated-at="2026-01-15 09:58:01.0072"></member->
<member- id="2" email="freddyjkrueger@gmail.com" first-name="Freddy" last-name="Krueger" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Villain" bio="This is my bio" notes="no notes" created="2026-01-13 13:38:46.0810" updated-at="2026-01-13 13:38:46.0810"></member->
<member- id="3" email="harmysmarmy@gmail.com" first-name="Harmy" last-name="Smarmy" password="$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-01-13 13:41:41.0722" updated-at="2026-01-13 13:41:41.0722"></member->
<member- id="4" email="matiascarulli@gmail.com" first-name="Matias" last-name="Carulli" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 address NW 12th St" city="Miramar" state="Florida" zipcode="33029" country="US" county="Broward" phone="123-456-789" title="Developer" bio="This is my bio" image-path="/db/images/users/member-4/profile.png" notes="no notes" created="2026-03-15 13:41:41.0722" updated-at="2026-03-15 13:41:41.0722"></member->
<member- id="5" email="boulder@example.com" first-name="CU" last-name="Boulder" password="$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-03-26 01:03:18.803016+00" updated-at="2026-03-26 01:03:18.803016+00"></member->
<member- id="6" email="sarah.mcintyre@example.com" first-name="Sarah" last-name="McIntyre" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="7" email="marcus.webb@example.com" first-name="Marcus" last-name="Webb" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Engineer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="8" email="priya.anand@example.com" first-name="Priya" last-name="Anand" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="PM" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="9" email="jordan.kim@example.com" first-name="Jordan" last-name="Kim" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<app- id="1" name="settings"></app->
<app- id="2" name="people"></app->
<app- id="3" name="calendar"></app->
<app- id="4" name="treasury"></app->
<app- id="5" name="politics"></app->
<app- id="6" name="files"></app->
<app- id="7" name="jobs"></app->
<app- id="8" name="tasks"></app->
<app- id="9" name="chat"></app->
<app- id="10" name="announcements"></app->
<permission- id="1" key="events.add" app-id="3" description="Can add events"></permission->
<permission- id="2" key="events.delete" app-id="3" description="Can delete events"></permission->
<permission- id="3" key="events.edit" app-id="3" description="Can edit events"></permission->
<permission- id="4" key="events.get" app-id="3" description="Can view events"></permission->
<permission- id="5" key="jobs.add" app-id="7" description="Can add jobs"></permission->
<permission- id="6" key="jobs.delete" app-id="7" description="Can delete jobs"></permission->
<permission- id="7" key="jobs.edit" app-id="7" description="Can edit jobs"></permission->
<permission- id="8" key="jobs.get" app-id="7" description="Can view jobs"></permission->
<permission- id="9" key="announcements.add" app-id="10" description="Can add announcements"></permission->
<permission- id="10" key="announcements.delete" app-id="10" description="Can delete announcements"></permission->
<permission- id="11" key="announcements.edit" app-id="10" description="Can edit announcements"></permission->
<permission- id="12" key="announcements.get" app-id="10" description="Can view announcements"></permission->
<permission- id="13" key="role_apps.edit" app-id="1" description="Can edit role apps"></permission->
<permission- id="14" key="roles.create" app-id="1" description="Can create roles"></permission->
<permission- id="15" key="roles.delete" app-id="1" description="Can delete roles"></permission->
<permission- id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications"></permission->
<permission- id="17" key="chats.create" app-id="9" description="Can create chats"></permission->
<permission- id="18" key="chats.edit" app-id="9" description="Can edit chats"></permission->
<permission- id="19" key="chats.delete" app-id="9" description="Can delete chats"></permission->
<permission- id="20" key="chats_message.send" app-id="9" description="Can send messages"></permission->
<permission- id="21" key="chats_message.edit" app-id="9" description="Can edit messages"></permission->
<permission- id="22" key="chats_message.delete" app-id="9" description="Can delete messages"></permission->
<member-app- id="1" member-id="1" app-id="1"></member-app->
<member-app- id="2" member-id="4" app-id="1"></member-app->
<member-app- id="3" member-id="6" app-id="1"></member-app->
<member-app- id="4" member-id="1" app-id="2"></member-app->
<member-app- id="5" member-id="4" app-id="2"></member-app->
<member-app- id="6" member-id="6" app-id="2"></member-app->
<member-app- id="7" member-id="1" app-id="3"></member-app->
<member-app- id="8" member-id="4" app-id="3"></member-app->
<member-app- id="9" member-id="6" app-id="3"></member-app->
<member-app- id="10" member-id="1" app-id="4"></member-app->
<member-app- id="11" member-id="4" app-id="4"></member-app->
<member-app- id="12" member-id="6" app-id="4"></member-app->
<member-app- id="13" member-id="1" app-id="5"></member-app->
<member-app- id="14" member-id="4" app-id="5"></member-app->
<member-app- id="15" member-id="6" app-id="5"></member-app->
<member-app- id="16" member-id="1" app-id="6"></member-app->
<member-app- id="17" member-id="4" app-id="6"></member-app->
<member-app- id="18" member-id="6" app-id="6"></member-app->
<member-app- id="19" member-id="1" app-id="7"></member-app->
<member-app- id="20" member-id="4" app-id="7"></member-app->
<member-app- id="21" member-id="6" app-id="7"></member-app->
<member-app- id="22" member-id="1" app-id="8"></member-app->
<member-app- id="23" member-id="4" app-id="8"></member-app->
<member-app- id="24" member-id="6" app-id="8"></member-app->
<member-app- id="25" member-id="1" app-id="9"></member-app->
<member-app- id="26" member-id="4" app-id="10"></member-app->
<member-app- id="27" member-id="6" app-id="9"></member-app->
<!-- network: comalyr -->
<!-- network 2: Hyperia -->
<network- id="2" name="Hyperia" logo="hyperia.svg" abbreviation="hyperia" stripe-account-id="acct_1S4w0GHZemeF9CKR" created="2026-01-10 09:58:01.0074"></network->
<join-code- id="2" code="hyperia" network-id="2"></join-code->
<member-network- id="4" member-id="1" network-id="2" created="2026-01-13 13:28:35.0701"></member-network->
<member-network- id="5" member-id="4" network-id="2" created="2026-03-15 13:28:35.0701"></member-network->
<member-network- id="6" member-id="6" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
<member-network- id="7" member-id="7" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
<member-network- id="8" member-id="8" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
<member-network- id="9" member-id="9" network-id="2" created="2026-02-01 09:00:00+00"></member-network->
<role- id="3" network-id="2" name="admin" is-default="false"></role->
<role- id="4" network-id="2" name="member" is-default="true"></role->
<role-app- id="11" role-id="3" app-id="1" network-id="2"></role-app->
<role-app- id="12" role-id="3" app-id="2" network-id="2"></role-app->
<role-app- id="13" role-id="3" app-id="3" network-id="2"></role-app->
<role-app- id="14" role-id="3" app-id="4" network-id="2"></role-app->
<role-app- id="15" role-id="3" app-id="5" network-id="2"></role-app->
<role-app- id="16" role-id="3" app-id="6" network-id="2"></role-app->
<role-app- id="17" role-id="3" app-id="7" network-id="2"></role-app->
<role-app- id="18" role-id="3" app-id="8" network-id="2"></role-app->
<role-app- id="19" role-id="3" app-id="9" network-id="2"></role-app->
<role-app- id="20" role-id="3" app-id="10" network-id="2"></role-app->
<role-permission- id="29" role-id="3" permission-key="events.get" network-id="2"></role-permission->
<role-permission- id="30" role-id="3" permission-key="jobs.get" network-id="2"></role-permission->
<role-permission- id="31" role-id="3" permission-key="announcements.get" network-id="2"></role-permission->
<role-permission- id="32" role-id="3" permission-key="events.add" network-id="2"></role-permission->
<role-permission- id="33" role-id="3" permission-key="events.delete" network-id="2"></role-permission->
<role-permission- id="34" role-id="3" permission-key="events.edit" network-id="2"></role-permission->
<role-permission- id="35" role-id="3" permission-key="jobs.add" network-id="2"></role-permission->
<role-permission- id="36" role-id="3" permission-key="jobs.delete" network-id="2"></role-permission->
<role-permission- id="37" role-id="3" permission-key="jobs.edit" network-id="2"></role-permission->
<role-permission- id="38" role-id="3" permission-key="announcements.add" network-id="2"></role-permission->
<role-permission- id="39" role-id="3" permission-key="announcements.delete" network-id="2"></role-permission->
<role-permission- id="40" role-id="3" permission-key="announcements.edit" network-id="2"></role-permission->
<role-permission- id="41" role-id="3" permission-key="role_apps.edit" network-id="2"></role-permission->
<role-permission- id="42" role-id="3" permission-key="roles.create" network-id="2"></role-permission->
<role-permission- id="43" role-id="3" permission-key="roles.delete" network-id="2"></role-permission->
<role-permission- id="44" role-id="3" permission-key="chats.create" network-id="2"></role-permission->
<role-permission- id="45" role-id="3" permission-key="chats.edit" network-id="2"></role-permission->
<role-permission- id="46" role-id="3" permission-key="chats.delete" network-id="2"></role-permission->
<role-permission- id="47" role-id="3" permission-key="chats_message.send" network-id="2"></role-permission->
<role-permission- id="48" role-id="3" permission-key="chats_message.edit" network-id="2"></role-permission->
<role-permission- id="49" role-id="3" permission-key="chats_message.delete" network-id="2"></role-permission->
<role-permission- id="50" role-id="3" permission-key="role_notifications.edit" network-id="2"></role-permission->
<role-permission- id="51" role-id="4" permission-key="events.get" network-id="2"></role-permission->
<role-permission- id="52" role-id="4" permission-key="jobs.get" network-id="2"></role-permission->
<role-permission- id="53" role-id="4" permission-key="announcements.get" network-id="2"></role-permission->
<role-permission- id="54" role-id="4" permission-key="events.add" network-id="2"></role-permission->
<role-permission- id="55" role-id="4" permission-key="chats.create" network-id="2"></role-permission->
<role-permission- id="56" role-id="4" permission-key="chats_message.send" network-id="2"></role-permission->
<member-role- id="4" member-id="1" role-id="3" granted-by="1" network-id="2"></member-role->
<member-role- id="5" member-id="4" role-id="3" granted-by="1" network-id="2"></member-role->
<member-role- id="6" member-id="6" role-id="3" granted-by="1" network-id="2"></member-role->
<member-role- id="7" member-id="6" role-id="4" granted-by="1" network-id="2"></member-role->
<member-role- id="8" member-id="7" role-id="4" granted-by="1" network-id="2"></member-role->
<member-role- id="9" member-id="8" role-id="4" granted-by="1" network-id="2"></member-role->
<member-role- id="10" member-id="9" role-id="4" granted-by="1" network-id="2"></member-role->
<calendar- schema="events" id="1" network-id="2" owner-id="1" name="Main Calendar" description="The main calendar for the network" color="#9E1C29"></calendar->
<calendar- schema="events" id="2" network-id="2" owner-id="1" name="Sub-Calendar" description="Sub-calendar for the network" color="#3D6FAD"></calendar->
<calendar- schema="events" id="3" network-id="2" owner-id="1" name="Sub-Calendar 2" description="Another sub-calendar for the network" color="#2A8636"></calendar->
<event-recurrence- schema="events" id="1" frequency="weekly" interval="1" days-of-week="{2}" network-id="2"></event-recurrence->
<event-recurrence- schema="events" id="2" frequency="weekly" interval="2" days-of-week="{3}" count="10" network-id="2"></event-recurrence->
<event-recurrence- schema="events" id="3" frequency="weekly" interval="1" days-of-week="{1,3,5}" network-id="2"></event-recurrence->
<event- schema="events" id="1" network-id="2" creator-id="1" title="Client meeting" description="Meeting with big client for app deployment" time-start="2026-04-01T17:30:00.000Z" time-end="2026-04-01T19:00:00.000Z" location="Virtual" all-day="false" recurrence-id="2"></event->
<event- schema="events" id="2" network-id="2" creator-id="1" title="Networking Event" description="Networking event for young professionals" time-start="2026-04-04T04:00:00.000Z" time-end="2026-04-06T04:00:00.000Z" location="GB Center" all-day="true"></event->
<event- schema="events" id="3" network-id="2" creator-id="1" title="App deployment party" description="Come celebrate the app deployment!" time-start="2026-04-05T16:00:00.000Z" time-end="2026-04-05T21:30:00.000Z" location="Captured Sun HQ" all-day="false"></event->
<event- schema="events" id="4" network-id="2" creator-id="4" title="Work on frontend changes" description="Reminder for work #1" time-start="2026-04-02T15:00:00.000Z" time-end="2026-04-02T19:00:00.000Z" all-day="false"></event->
<event- schema="events" id="5" network-id="2" creator-id="4" title="Day off" description="I dont have to work today" time-start="2026-04-03T04:00:00.000Z" time-end="2026-04-03T04:00:00.000Z" all-day="true"></event->
<event- schema="events" id="6" network-id="2" creator-id="4" title="Scrum Meeting - Main Team" description="Agile Week 32" time-start="2026-03-31T03:00:00.000Z" time-end="2026-03-31T06:15:00.000Z" location="Virtual" all-day="false" recurrence-id="1"></event->
<event- schema="events" id="7" network-id="2" creator-id="1" title="Meeting with John Smiith" description="Lorem ipsum elorum" time-start="2026-03-31T18:30:00.000Z" time-end="2026-03-31T19:30:00.000Z" location="Virtual" all-day="false"></event->
<event- schema="events" id="8" network-id="2" creator-id="1" title="Meeting with Jane Doe" description="Lorem ipsum elorum" time-start="2026-03-31T19:30:00.000Z" time-end="2026-03-31T19:45:00.000Z" location="Virtual" all-day="false"></event->
<event- schema="events" id="9" network-id="2" creator-id="1" title="Meeting with president" description="Lorem ipsum elorum" time-start="2026-03-31T19:45:00.000Z" time-end="2026-03-31T21:15:00.000Z" location="Virtual" all-day="false"></event->
<event- schema="events" id="10" network-id="2" creator-id="1" title="Meeting with investors" description="Lorem ipsum elorum" time-start="2026-03-31T21:30:00.000Z" time-end="2026-03-31T23:00:00.000Z" location="Virtual" all-day="false"></event->
<event- schema="events" id="11" network-id="2" creator-id="1" title="Review Github changes" description="Review pushes from members to ensure all is well" time-start="2026-04-03T04:00:00.000Z" time-end="2026-04-03T04:00:00.000Z" all-day="true" recurrence-id="3"></event->
<event-calendar- schema="events" id="1" event-id="1" calendar-id="1" network-id="2"></event-calendar->
<event-calendar- schema="events" id="2" event-id="2" calendar-id="2" network-id="2"></event-calendar->
<event-calendar- schema="events" id="3" event-id="3" calendar-id="1" network-id="2"></event-calendar->
<event-calendar- schema="events" id="4" event-id="3" calendar-id="3" network-id="2"></event-calendar->
<event-calendar- schema="events" id="5" event-id="4" calendar-id="2" network-id="2"></event-calendar->
<event-calendar- schema="events" id="6" event-id="5" calendar-id="2" network-id="2"></event-calendar->
<event-calendar- schema="events" id="7" event-id="6" calendar-id="1" network-id="2"></event-calendar->
<event-calendar- schema="events" id="8" event-id="6" calendar-id="2" network-id="2"></event-calendar->
<event-calendar- schema="events" id="9" event-id="7" calendar-id="3" network-id="2"></event-calendar->
<event-calendar- schema="events" id="10" event-id="8" calendar-id="2" network-id="2"></event-calendar->
<event-calendar- schema="events" id="11" event-id="8" calendar-id="3" network-id="2"></event-calendar->
<event-calendar- schema="events" id="12" event-id="9" calendar-id="3" network-id="2"></event-calendar->
<event-calendar- schema="events" id="13" event-id="10" calendar-id="3" network-id="2"></event-calendar->
<event-calendar- schema="events" id="14" event-id="11" calendar-id="3" network-id="2"></event-calendar->
<job- id="1" network-id="2" creator-id="1" title="Senior Frontend Engineer" company="Acme Corp" location="San Francisco, CA" employment-type="full-time" experience-level="senior" department="Engineering" salary-number="165000" salary-period="year" applicants="47" skills="React,TypeScript,CSS,GraphQL,Node.js" description="We&#x27;re looking for a Senior Frontend Engineer to join our growing product team. You&#x27;ll work closely with designers, backend engineers, and product managers to build fast, accessible, and beautiful web experiences.&#10;&#10;You&#x27;ll have ownership over large features and be expected to make architectural decisions. We ship every week and care deeply about code quality and UX." created="2026-04-29 10:00:00" updated-at="2026-04-29 10:00:00"></job->
<job- id="2" network-id="2" creator-id="1" title="Product Designer" company="Blue River" location="New York, NY" employment-type="full-time" experience-level="mid" department="Design" salary-number="120000" salary-period="year" applicants="112" skills="Figma,Design Systems,Prototyping,User Research" description="Blue River is hiring a Product Designer to lead design across our core consumer product. You&#x27;ll partner with PMs and engineers to take features from zero to one, establish design patterns, and advocate for users in every decision.&#10;&#10;We&#x27;re a small, fast-moving team and designers have a huge impact here." created="2026-04-26 09:00:00" updated-at="2026-04-26 09:00:00"></job->
<job- id="3" network-id="2" creator-id="1" title="Backend Engineer" company="Orbit Systems" location="Remote" employment-type="contract" experience-level="mid" department="Platform" salary-number="95" salary-period="hour" applicants="29" skills="Go,PostgreSQL,Kubernetes,gRPC,Redis" description="6-month contract (potential to extend) for a backend engineer to help scale our platform infrastructure. You&#x27;ll be responsible for building and maintaining APIs used by millions of users, optimizing database performance, and improving system reliability." created="2026-04-30 14:00:00" updated-at="2026-04-30 14:00:00"></job->
<job- id="4" network-id="2" creator-id="1" title="Marketing Manager" company="Groundwork" location="Austin, TX" employment-type="full-time" experience-level="mid" department="Marketing" salary-number="98000" salary-period="year" applicants="88" skills="SEO,Content Strategy,Analytics,Paid Acquisition,Email Marketing" description="We need a Marketing Manager to own our top-of-funnel growth. You&#x27;ll manage content, paid channels, and email campaigns — and be the person who keeps the brand consistent across every touchpoint. You&#x27;ll report directly to our Head of Growth." created="2026-04-21 11:00:00" updated-at="2026-04-21 11:00:00"></job->
<job- id="5" network-id="2" creator-id="1" title="Data Analyst" company="Compass Data" location="Chicago, IL" employment-type="full-time" experience-level="entry" department="Analytics" salary-number="75000" salary-period="year" applicants="203" skills="SQL,Python,Tableau,dbt,Excel" description="A great entry-level opportunity for someone who loves data. You&#x27;ll work with our analytics team to build dashboards, run ad-hoc analysis, and help business teams make data-driven decisions. We&#x27;ll invest in your growth and give you plenty of mentorship." created="2026-04-28 08:30:00" updated-at="2026-04-28 08:30:00"></job->
<job- id="6" network-id="2" creator-id="1" title="iOS Engineer" company="Fieldwork" location="Seattle, WA" employment-type="full-time" experience-level="senior" department="Mobile" salary-number="175000" salary-period="year" applicants="34" skills="Swift,SwiftUI,Combine,CoreData,Xcode" description="Fieldwork is building next-generation tools for field service teams. Our iOS app is the most important surface we have — technicians use it every day on job sites. We need a senior iOS engineer who cares about performance, offline reliability, and a great UX." created="2026-04-24 13:00:00" updated-at="2026-04-24 13:00:00"></job->
<job- id="7" network-id="2" creator-id="1" title="Operations Coordinator" company="Maple &amp; Co" location="Boston, MA" employment-type="part-time" experience-level="entry" department="Operations" salary-number="28" salary-period="hour" applicants="61" skills="Project Management,Excel,Communication,Scheduling" description="Part-time role (20 hrs/week) helping our operations team stay organized. You&#x27;ll coordinate schedules, manage vendor relationships, and help improve internal workflows. Ideal for someone who&#x27;s highly organized and excited to grow into a full-time ops role." created="2026-04-17 10:00:00" updated-at="2026-04-17 10:00:00"></job->
<job- id="8" network-id="2" creator-id="1" title="Machine Learning Intern" company="NeuralPath" location="Remote" employment-type="internship" experience-level="entry" department="AI Research" salary-number="8000" salary-period="month" applicants="394" skills="Python,PyTorch,Linear Algebra,Git" description="Summer internship (12 weeks) on our ML research team. You&#x27;ll work alongside researchers on real problems in NLP and recommendation systems. We expect you to ship something you&#x27;re proud of by the end of the summer. Strong preference for candidates who can start June 2." created="2026-05-01 09:00:00" updated-at="2026-05-01 09:00:00"></job->
<announcement- id="1" network-id="2" creator-id="1" text="This is the first announcement" created="2026-01-13 15:37:00.0000" updated-at="2026-01-13 15:37:00.0000"></announcement->
<announcement- id="2" network-id="2" creator-id="1" text="Here goes another announcement." created="2026-01-13 15:39:00.0000" updated-at="2026-01-13 15:39:00.0000"></announcement->
<announcement- id="3" network-id="2" creator-id="4" text="My first announcement!" created="2026-01-14 15:41:00.0000" updated-at="2026-01-14 15:41:00.0000"></announcement->
<announcement- id="4" network-id="2" creator-id="1" text="Testing announcement." created="2026-02-17 12:30:00.0000" updated-at="2026-02-17 12:30:00.0000"></announcement->
<announcement- id="5" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:56:00.0000" updated-at="2026-03-23 15:56:00.0000"></announcement->
<announcement- id="6" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:56:30.0000" updated-at="2026-03-23 15:56:30.0000"></announcement->
<announcement- id="7" network-id="2" creator-id="4" text="Quick fire!" created="2026-03-23 15:57:00.0000" updated-at="2026-03-23 15:57:00.0000"></announcement->
<announcement- id="8" network-id="2" creator-id="1" text="Trying another user." created="2026-02-05 15:56:30.0000" updated-at="2026-02-05 15:56:30.0000"></announcement->
<announcement- id="9" network-id="2" creator-id="1" text="One last announcement." created="2026-04-01 15:57:00.0000" updated-at="2026-01-13 15:57:00.0000"></announcement->
<chat- schema="chats" id="1" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:42:00+00"></chat->
<chat- schema="chats" id="2" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 10:30:00+00"></chat->
<chat- schema="chats" id="3" network-id="2" creator-id="4" type="dm" created="2026-04-01 09:00:00+00" updated-at="2026-04-30 18:00:00+00"></chat->
<chat- schema="chats" id="4" network-id="2" creator-id="4" type="group" name="Product Team" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:51:00+00"></chat->
<chat- schema="chats" id="5" network-id="2" creator-id="4" type="group" name="Design Review" created="2026-04-01 09:00:00+00" updated-at="2026-04-30 10:00:00+00"></chat->
<chat- schema="chats" id="6" network-id="2" creator-id="4" type="channel" name="general" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 11:55:00+00"></chat->
<chat- schema="chats" id="7" network-id="2" creator-id="4" type="channel" name="engineering" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00"></chat->
<chat- schema="chats" id="8" network-id="2" creator-id="8" type="announcement" name="Announcements" created="2026-04-01 09:00:00+00" updated-at="2026-05-01 00:00:00+00"></chat->
<chat-member- schema="chats" id="1" chat-id="1" member-id="4" last-read-at="2026-05-01 07:12:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="2" chat-id="1" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="3" chat-id="2" member-id="4" last-read-at="2026-05-01 10:30:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="4" chat-id="2" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="5" chat-id="3" member-id="4" last-read-at="2026-04-30 18:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="6" chat-id="3" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="7" chat-id="4" member-id="4" last-read-at="2026-05-01 04:30:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="8" chat-id="4" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="9" chat-id="4" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="10" chat-id="4" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="11" chat-id="5" member-id="4" last-read-at="2026-04-30 10:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="12" chat-id="5" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="13" chat-id="5" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="14" chat-id="6" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="15" chat-id="6" member-id="4" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="16" chat-id="6" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="17" chat-id="6" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="18" chat-id="6" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="19" chat-id="6" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="20" chat-id="7" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="21" chat-id="7" member-id="4" last-read-at="2026-05-01 09:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="22" chat-id="7" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="23" chat-id="8" member-id="1" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="24" chat-id="8" member-id="4" last-read-at="2026-04-29 12:00:00+00" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="25" chat-id="8" member-id="6" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="26" chat-id="8" member-id="7" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="27" chat-id="8" member-id="8" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<chat-member- schema="chats" id="28" chat-id="8" member-id="9" joined-at="2026-04-01 09:00:00+00" network-id="2"></chat-member->
<message- schema="chats" id="1" chat-id="1" sender-id="4" text="Hey Sarah, did you see the new design mockups?" sent-at="2026-04-30 12:00:00+00" updated-at="2026-04-30 12:00:00+00" network-id="2"></message->
<message- schema="chats" id="2" chat-id="1" sender-id="6" text="Just looked — they&#x27;re really clean. I love the new sidebar." sent-at="2026-04-30 12:30:00+00" updated-at="2026-04-30 12:30:00+00" network-id="2"></message->
<message- schema="chats" id="3" chat-id="1" sender-id="4" text="Agreed. Alex did a great job." sent-at="2026-04-30 12:36:00+00" updated-at="2026-04-30 12:36:00+00" network-id="2"></message->
<message- schema="chats" id="4" chat-id="1" sender-id="6" text="Are we going to ship this week or wait for the backend?" sent-at="2026-05-01 07:00:00+00" updated-at="2026-05-01 07:00:00+00" network-id="2"></message->
<message- schema="chats" id="5" chat-id="1" sender-id="4" text="Let&#x27;s aim for Thursday. I&#x27;ll sync with Marcus." sent-at="2026-05-01 07:12:00+00" updated-at="2026-05-01 07:12:00+00" network-id="2"></message->
<message- schema="chats" id="6" chat-id="1" sender-id="6" text="Sounds good 👍" sent-at="2026-05-01 07:18:00+00" updated-at="2026-05-01 07:18:00+00" network-id="2"></message->
<message- schema="chats" id="7" chat-id="1" sender-id="6" text="Can you review the PR when you get a chance?" sent-at="2026-05-01 11:42:00+00" updated-at="2026-05-01 11:42:00+00" network-id="2"></message->
<message- schema="chats" id="8" chat-id="2" sender-id="7" text="Hey, the API endpoint is returning 500s on staging." sent-at="2026-05-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00" network-id="2"></message->
<message- schema="chats" id="9" chat-id="2" sender-id="4" text="Oh no — is it the auth middleware again?" sent-at="2026-05-01 09:06:00+00" updated-at="2026-05-01 09:06:00+00" network-id="2"></message->
<message- schema="chats" id="10" chat-id="2" sender-id="7" text="Yep. Same issue as last week." sent-at="2026-05-01 09:12:00+00" updated-at="2026-05-01 09:12:00+00" network-id="2"></message->
<message- schema="chats" id="11" chat-id="2" sender-id="4" text="I&#x27;ll patch it now. Give me 20 mins." sent-at="2026-05-01 09:15:00+00" updated-at="2026-05-01 09:15:00+00" network-id="2"></message->
<message- schema="chats" id="12" chat-id="2" sender-id="7" text="Thanks, no rush." sent-at="2026-05-01 09:18:00+00" updated-at="2026-05-01 09:18:00+00" network-id="2"></message->
<message- schema="chats" id="13" chat-id="2" sender-id="4" text="Fixed. Can you redeploy and check?" sent-at="2026-05-01 09:48:00+00" updated-at="2026-05-01 09:48:00+00" network-id="2"></message->
<message- schema="chats" id="14" chat-id="2" sender-id="7" text="All green 🎉 Thanks!" sent-at="2026-05-01 09:54:00+00" updated-at="2026-05-01 09:54:00+00" network-id="2"></message->
<message- schema="chats" id="15" chat-id="2" sender-id="4" text="I&#x27;ll send over the specs by EOD" sent-at="2026-05-01 10:30:00+00" updated-at="2026-05-01 10:30:00+00" network-id="2"></message->
<message- schema="chats" id="16" chat-id="3" sender-id="8" text="Quick question — what&#x27;s the launch date for v2?" sent-at="2026-04-30 16:00:00+00" updated-at="2026-04-30 16:00:00+00" network-id="2"></message->
<message- schema="chats" id="17" chat-id="3" sender-id="4" text="Still TBD, but we&#x27;re targeting end of May." sent-at="2026-04-30 16:12:00+00" updated-at="2026-04-30 16:12:00+00" network-id="2"></message->
<message- schema="chats" id="18" chat-id="3" sender-id="8" text="Got it. I&#x27;ll update the roadmap doc." sent-at="2026-04-30 16:30:00+00" updated-at="2026-04-30 16:30:00+00" network-id="2"></message->
<message- schema="chats" id="19" chat-id="3" sender-id="4" text="Perfect, thanks Priya." sent-at="2026-04-30 16:36:00+00" updated-at="2026-04-30 16:36:00+00" network-id="2"></message->
<message- schema="chats" id="20" chat-id="3" sender-id="8" text="See you at the standup!" sent-at="2026-04-30 18:00:00+00" updated-at="2026-04-30 18:00:00+00" network-id="2"></message->
<message- schema="chats" id="21" chat-id="4" sender-id="7" text="Morning everyone! API docs are updated." sent-at="2026-05-01 04:00:00+00" updated-at="2026-05-01 04:00:00+00" network-id="2"></message->
<message- schema="chats" id="22" chat-id="4" sender-id="8" text="Nice work Marcus 🙌" sent-at="2026-05-01 04:06:00+00" updated-at="2026-05-01 04:06:00+00" network-id="2"></message->
<message- schema="chats" id="23" chat-id="4" sender-id="4" text="I&#x27;ll start on the integration tests today." sent-at="2026-05-01 04:12:00+00" updated-at="2026-05-01 04:12:00+00" network-id="2"></message->
<message- schema="chats" id="24" chat-id="4" sender-id="6" text="Great. I&#x27;m finishing up the onboarding screens." sent-at="2026-05-01 04:30:00+00" updated-at="2026-05-01 04:30:00+00" network-id="2"></message->
<message- schema="chats" id="25" chat-id="4" sender-id="8" text="Can we do a quick sync at 2pm?" sent-at="2026-05-01 10:00:00+00" updated-at="2026-05-01 10:00:00+00" network-id="2"></message->
<message- schema="chats" id="26" chat-id="4" sender-id="4" text="Works for me." sent-at="2026-05-01 10:03:00+00" updated-at="2026-05-01 10:03:00+00" network-id="2"></message->
<message- schema="chats" id="27" chat-id="4" sender-id="7" text="Same" sent-at="2026-05-01 10:06:00+00" updated-at="2026-05-01 10:06:00+00" network-id="2"></message->
<message- schema="chats" id="28" chat-id="4" sender-id="6" text="I&#x27;ll send the invite." sent-at="2026-05-01 10:09:00+00" updated-at="2026-05-01 10:09:00+00" network-id="2"></message->
<message- schema="chats" id="29" chat-id="4" sender-id="6" text="I&#x27;ve updated the Figma file with the new flows" sent-at="2026-05-01 11:51:00+00" updated-at="2026-05-01 11:51:00+00" network-id="2"></message->
<message- schema="chats" id="30" chat-id="5" sender-id="9" text="Hey, sharing the first round of designs for the settings page." sent-at="2026-04-30 06:00:00+00" updated-at="2026-04-30 06:00:00+00" network-id="2"></message->
<message- schema="chats" id="31" chat-id="5" sender-id="6" text="These look great! Love the card layout." sent-at="2026-04-30 06:30:00+00" updated-at="2026-04-30 06:30:00+00" network-id="2"></message->
<message- schema="chats" id="32" chat-id="5" sender-id="4" text="Agreed. One thought — the spacing on the form feels a bit tight." sent-at="2026-04-30 07:00:00+00" updated-at="2026-04-30 07:00:00+00" network-id="2"></message->
<message- schema="chats" id="33" chat-id="5" sender-id="9" text="Good call. I&#x27;ll loosen it up." sent-at="2026-04-30 07:12:00+00" updated-at="2026-04-30 07:12:00+00" network-id="2"></message->
<message- schema="chats" id="34" chat-id="5" sender-id="6" text="Also maybe we increase the font size slightly?" sent-at="2026-04-30 08:00:00+00" updated-at="2026-04-30 08:00:00+00" network-id="2"></message->
<message- schema="chats" id="35" chat-id="5" sender-id="9" text="The contrast on mobile looks off — can we bump it?" sent-at="2026-04-30 10:00:00+00" updated-at="2026-04-30 10:00:00+00" network-id="2"></message->
<message- schema="chats" id="36" chat-id="6" sender-id="8" text="Good morning team! Reminder: all-hands is Thursday at 10am." sent-at="2026-05-01 03:00:00+00" updated-at="2026-05-01 03:00:00+00" network-id="2"></message->
<message- schema="chats" id="37" chat-id="6" sender-id="6" text="Thanks for the reminder!" sent-at="2026-05-01 03:06:00+00" updated-at="2026-05-01 03:06:00+00" network-id="2"></message->
<message- schema="chats" id="38" chat-id="6" sender-id="9" text="Will there be a recording for those in other time zones?" sent-at="2026-05-01 03:18:00+00" updated-at="2026-05-01 03:18:00+00" network-id="2"></message->
<message- schema="chats" id="39" chat-id="6" sender-id="8" text="Yes — I&#x27;ll post the link in #announcements after." sent-at="2026-05-01 03:24:00+00" updated-at="2026-05-01 03:24:00+00" network-id="2"></message->
<message- schema="chats" id="40" chat-id="6" sender-id="4" text="Thanks Priya 🙏" sent-at="2026-05-01 03:30:00+00" updated-at="2026-05-01 03:30:00+00" network-id="2"></message->
<message- schema="chats" id="41" chat-id="6" sender-id="7" text="Staging is back up btw, had a brief outage this morning." sent-at="2026-05-01 08:00:00+00" updated-at="2026-05-01 08:00:00+00" network-id="2"></message->
<message- schema="chats" id="42" chat-id="6" sender-id="6" text="Oh I didn&#x27;t even notice, nice quick fix!" sent-at="2026-05-01 08:12:00+00" updated-at="2026-05-01 08:12:00+00" network-id="2"></message->
<message- schema="chats" id="43" chat-id="6" sender-id="7" text="Just pushed the hotfix to production" sent-at="2026-05-01 11:55:00+00" updated-at="2026-05-01 11:55:00+00" network-id="2"></message->
<message- schema="chats" id="44" chat-id="7" sender-id="7" text="Heads up: I&#x27;m updating the CI pipeline today. Builds might be slow for a bit." sent-at="2026-05-01 07:00:00+00" updated-at="2026-05-01 07:00:00+00" network-id="2"></message->
<message- schema="chats" id="45" chat-id="7" sender-id="4" text="Noted, thanks for the warning." sent-at="2026-05-01 07:06:00+00" updated-at="2026-05-01 07:06:00+00" network-id="2"></message->
<message- schema="chats" id="46" chat-id="7" sender-id="7" text="Back to normal now." sent-at="2026-05-01 08:00:00+00" updated-at="2026-05-01 08:00:00+00" network-id="2"></message->
<message- schema="chats" id="47" chat-id="7" sender-id="4" text="PR is up: #247 — adds rate limiting to the auth routes" sent-at="2026-05-01 09:00:00+00" updated-at="2026-05-01 09:00:00+00" network-id="2"></message->
<message- schema="chats" id="48" chat-id="8" sender-id="8" text="Welcome to the team, Jordan! 🎉 Jordan joins us as a Product Designer." sent-at="2026-04-28 12:00:00+00" updated-at="2026-04-28 12:00:00+00" network-id="2"></message->
<message- schema="chats" id="49" chat-id="8" sender-id="8" text="Reminder: expense reports for March are due this Friday." sent-at="2026-04-29 12:00:00+00" updated-at="2026-04-29 12:00:00+00" network-id="2"></message->
<message- schema="chats" id="50" chat-id="8" sender-id="8" text="Q2 planning kick-off is next Monday at 9am. Please come prepared with your team&#x27;s priorities." sent-at="2026-05-01 00:00:00+00" updated-at="2026-05-01 00:00:00+00" network-id="2"></message->
</forms->

134
priv/networks/cs/index.html Normal file
View File

@@ -0,0 +1,134 @@
<forms->
<!-- global -->
<member- id="1" email="samrussell99@pm.me" first-name="Sam" last-name="Russell" password="$argon2id$v=19$m=65536,t=3,p=4$n/8BaBisEnBaQNbkxzs1VA$dvvnupWNtB5w5qTBgEciDsNA6rOgXaEypcEK1A0ndLM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="CEO" bio="This is my bio" notes="no notes" created="2026-01-15 09:58:01.0072" updated-at="2026-01-15 09:58:01.0072"></member->
<member- id="2" email="freddyjkrueger@gmail.com" first-name="Freddy" last-name="Krueger" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Villain" bio="This is my bio" notes="no notes" created="2026-01-13 13:38:46.0810" updated-at="2026-01-13 13:38:46.0810"></member->
<member- id="3" email="harmysmarmy@gmail.com" first-name="Harmy" last-name="Smarmy" password="$argon2id$v=19$m=65536,t=3,p=4$FAhGtCtqNAQ19tBYD73wXQ$0AM/khyBFFuX2mv0ieqtGfsXRgtEldWKFwyeV3BA3Xk" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-01-13 13:41:41.0722" updated-at="2026-01-13 13:41:41.0722"></member->
<member- id="4" email="matiascarulli@gmail.com" first-name="Matias" last-name="Carulli" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 address NW 12th St" city="Miramar" state="Florida" zipcode="33029" country="US" county="Broward" phone="123-456-789" title="Developer" bio="This is my bio" image-path="/db/images/users/member-4/profile.png" notes="no notes" created="2026-03-15 13:41:41.0722" updated-at="2026-03-15 13:41:41.0722"></member->
<member- id="5" email="boulder@example.com" first-name="CU" last-name="Boulder" password="$argon2id$v=19$m=65536,t=3,p=4$CQwOYXNwwsLBP1s/zcZNJg$OM/wwVP5U+QUnAEDKAjk5mpvujpOzpT0XkouDcmHT8E" address1="1234 address NW 12th St" city="Austin" state="Texas" zipcode="12345" country="US" county="Austin County" phone="123-456-789" title="Associate" bio="This is my bio" notes="no notes" created="2026-03-26 01:03:18.803016+00" updated-at="2026-03-26 01:03:18.803016+00"></member->
<member- id="6" email="sarah.mcintyre@example.com" first-name="Sarah" last-name="McIntyre" password="$argon2id$v=19$m=65536,t=3,p=4$U2FsdGVkc3M$08SY0x7DUPwTV12ZckHdNg" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="7" email="marcus.webb@example.com" first-name="Marcus" last-name="Webb" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Engineer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="8" email="priya.anand@example.com" first-name="Priya" last-name="Anand" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="PM" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<member- id="9" email="jordan.kim@example.com" first-name="Jordan" last-name="Kim" password="$argon2id$v=19$m=65536,t=3,p=4$ioAYDPtyUulykMrH9W7q9A$lG43cq6Dj3/n1+bJrkupWpB5Xro3UIQaVd9rjuJJ6nM" address1="1234 Address St" city="Austin" state="Texas" zipcode="12345" country="US" county="Travis" phone="123-456-789" title="Designer" bio="This is my bio" created="2026-02-01 09:00:00+00" updated-at="2026-02-01 09:00:00+00"></member->
<app- id="1" name="settings"></app->
<app- id="2" name="people"></app->
<app- id="3" name="calendar"></app->
<app- id="4" name="treasury"></app->
<app- id="5" name="politics"></app->
<app- id="6" name="files"></app->
<app- id="7" name="jobs"></app->
<app- id="8" name="tasks"></app->
<app- id="9" name="chat"></app->
<app- id="10" name="announcements"></app->
<permission- id="1" key="events.add" app-id="3" description="Can add events"></permission->
<permission- id="2" key="events.delete" app-id="3" description="Can delete events"></permission->
<permission- id="3" key="events.edit" app-id="3" description="Can edit events"></permission->
<permission- id="4" key="events.get" app-id="3" description="Can view events"></permission->
<permission- id="5" key="jobs.add" app-id="7" description="Can add jobs"></permission->
<permission- id="6" key="jobs.delete" app-id="7" description="Can delete jobs"></permission->
<permission- id="7" key="jobs.edit" app-id="7" description="Can edit jobs"></permission->
<permission- id="8" key="jobs.get" app-id="7" description="Can view jobs"></permission->
<permission- id="9" key="announcements.add" app-id="10" description="Can add announcements"></permission->
<permission- id="10" key="announcements.delete" app-id="10" description="Can delete announcements"></permission->
<permission- id="11" key="announcements.edit" app-id="10" description="Can edit announcements"></permission->
<permission- id="12" key="announcements.get" app-id="10" description="Can view announcements"></permission->
<permission- id="13" key="role_apps.edit" app-id="1" description="Can edit role apps"></permission->
<permission- id="14" key="roles.create" app-id="1" description="Can create roles"></permission->
<permission- id="15" key="roles.delete" app-id="1" description="Can delete roles"></permission->
<permission- id="16" key="role_notifications.edit" app-id="1" description="Can edit role notifications"></permission->
<permission- id="17" key="chats.create" app-id="9" description="Can create chats"></permission->
<permission- id="18" key="chats.edit" app-id="9" description="Can edit chats"></permission->
<permission- id="19" key="chats.delete" app-id="9" description="Can delete chats"></permission->
<permission- id="20" key="chats_message.send" app-id="9" description="Can send messages"></permission->
<permission- id="21" key="chats_message.edit" app-id="9" description="Can edit messages"></permission->
<permission- id="22" key="chats_message.delete" app-id="9" description="Can delete messages"></permission->
<member-app- id="1" member-id="1" app-id="1"></member-app->
<member-app- id="2" member-id="4" app-id="1"></member-app->
<member-app- id="3" member-id="6" app-id="1"></member-app->
<member-app- id="4" member-id="1" app-id="2"></member-app->
<member-app- id="5" member-id="4" app-id="2"></member-app->
<member-app- id="6" member-id="6" app-id="2"></member-app->
<member-app- id="7" member-id="1" app-id="3"></member-app->
<member-app- id="8" member-id="4" app-id="3"></member-app->
<member-app- id="9" member-id="6" app-id="3"></member-app->
<member-app- id="10" member-id="1" app-id="4"></member-app->
<member-app- id="11" member-id="4" app-id="4"></member-app->
<member-app- id="12" member-id="6" app-id="4"></member-app->
<member-app- id="13" member-id="1" app-id="5"></member-app->
<member-app- id="14" member-id="4" app-id="5"></member-app->
<member-app- id="15" member-id="6" app-id="5"></member-app->
<member-app- id="16" member-id="1" app-id="6"></member-app->
<member-app- id="17" member-id="4" app-id="6"></member-app->
<member-app- id="18" member-id="6" app-id="6"></member-app->
<member-app- id="19" member-id="1" app-id="7"></member-app->
<member-app- id="20" member-id="4" app-id="7"></member-app->
<member-app- id="21" member-id="6" app-id="7"></member-app->
<member-app- id="22" member-id="1" app-id="8"></member-app->
<member-app- id="23" member-id="4" app-id="8"></member-app->
<member-app- id="24" member-id="6" app-id="8"></member-app->
<member-app- id="25" member-id="1" app-id="9"></member-app->
<member-app- id="26" member-id="4" app-id="10"></member-app->
<member-app- id="27" member-id="6" app-id="9"></member-app->
<!-- network: cs -->
<!-- network 1: Captured Sun -->
<network- id="1" name="Captured Sun" logo="cs.svg" abbreviation="cs" stripe-account-id="acct_1Sn6DwLpyskwAml9" created="2026-01-10 09:58:01.0074"></network->
<network-plan- id="1" network-id="1" stripe-price-id="price_1T3uaxLpyskwAml9p0r0nh2h" name="Patron Membership" price="200.00" description="Members 40+" active="true" created="2026-03-29 22:14:45.414163"></network-plan->
<network-plan- id="2" network-id="1" stripe-price-id="price_1T3uaQLpyskwAml9rZAKBcy0" name="Regular Membership" price="100.00" description="Members 18-40" active="true" created="2026-03-29 22:14:45.414163"></network-plan->
<join-code- id="1" code="cs" network-id="1"></join-code->
<member-network- id="1" member-id="1" network-id="1" created="2025-11-24 00:54:36.0784"></member-network->
<member-network- id="2" member-id="2" network-id="1" created="2026-01-13 13:14:28.0178"></member-network->
<member-network- id="3" member-id="3" network-id="1" created="2026-01-13 13:28:35.0701"></member-network->
<role- id="1" network-id="1" name="admin" is-default="false"></role->
<role- id="2" network-id="1" name="member" is-default="true"></role->
<role-app- id="1" role-id="1" app-id="1" network-id="1"></role-app->
<role-app- id="2" role-id="1" app-id="2" network-id="1"></role-app->
<role-app- id="3" role-id="1" app-id="3" network-id="1"></role-app->
<role-app- id="4" role-id="1" app-id="4" network-id="1"></role-app->
<role-app- id="5" role-id="1" app-id="5" network-id="1"></role-app->
<role-app- id="6" role-id="1" app-id="6" network-id="1"></role-app->
<role-app- id="7" role-id="1" app-id="7" network-id="1"></role-app->
<role-app- id="8" role-id="1" app-id="8" network-id="1"></role-app->
<role-app- id="9" role-id="1" app-id="9" network-id="1"></role-app->
<role-app- id="10" role-id="1" app-id="10" network-id="1"></role-app->
<role-permission- id="1" role-id="1" permission-key="events.get" network-id="1"></role-permission->
<role-permission- id="2" role-id="1" permission-key="jobs.get" network-id="1"></role-permission->
<role-permission- id="3" role-id="1" permission-key="announcements.get" network-id="1"></role-permission->
<role-permission- id="4" role-id="1" permission-key="events.add" network-id="1"></role-permission->
<role-permission- id="5" role-id="1" permission-key="events.delete" network-id="1"></role-permission->
<role-permission- id="6" role-id="1" permission-key="events.edit" network-id="1"></role-permission->
<role-permission- id="7" role-id="1" permission-key="jobs.add" network-id="1"></role-permission->
<role-permission- id="8" role-id="1" permission-key="jobs.delete" network-id="1"></role-permission->
<role-permission- id="9" role-id="1" permission-key="jobs.edit" network-id="1"></role-permission->
<role-permission- id="10" role-id="1" permission-key="announcements.add" network-id="1"></role-permission->
<role-permission- id="11" role-id="1" permission-key="announcements.delete" network-id="1"></role-permission->
<role-permission- id="12" role-id="1" permission-key="announcements.edit" network-id="1"></role-permission->
<role-permission- id="13" role-id="1" permission-key="role_apps.edit" network-id="1"></role-permission->
<role-permission- id="14" role-id="1" permission-key="roles.create" network-id="1"></role-permission->
<role-permission- id="15" role-id="1" permission-key="roles.delete" network-id="1"></role-permission->
<role-permission- id="16" role-id="1" permission-key="chats.create" network-id="1"></role-permission->
<role-permission- id="17" role-id="1" permission-key="chats.edit" network-id="1"></role-permission->
<role-permission- id="18" role-id="1" permission-key="chats.delete" network-id="1"></role-permission->
<role-permission- id="19" role-id="1" permission-key="chats_message.send" network-id="1"></role-permission->
<role-permission- id="20" role-id="1" permission-key="chats_message.edit" network-id="1"></role-permission->
<role-permission- id="21" role-id="1" permission-key="chats_message.delete" network-id="1"></role-permission->
<role-permission- id="22" role-id="1" permission-key="role_notifications.edit" network-id="1"></role-permission->
<role-permission- id="23" role-id="2" permission-key="events.get" network-id="1"></role-permission->
<role-permission- id="24" role-id="2" permission-key="jobs.get" network-id="1"></role-permission->
<role-permission- id="25" role-id="2" permission-key="announcements.get" network-id="1"></role-permission->
<role-permission- id="26" role-id="2" permission-key="events.add" network-id="1"></role-permission->
<role-permission- id="27" role-id="2" permission-key="chats.create" network-id="1"></role-permission->
<role-permission- id="28" role-id="2" permission-key="chats_message.send" network-id="1"></role-permission->
<member-role- id="1" member-id="1" role-id="1" granted-by="1" network-id="1"></member-role->
<member-role- id="2" member-id="2" role-id="2" granted-by="1" network-id="1"></member-role->
<member-role- id="3" member-id="3" role-id="2" granted-by="1" network-id="1"></member-role->
<join-form- schema="org_1" id="1" fname="James" lname="Mitchell" email="james.mitchell@gmail.com" phone="512-555-0101" county="Comal" time="2025-12-16 23:11:31.0011" network-id="1"></join-form->
<join-form- schema="org_1" id="2" fname="Rachel" lname="Torres" email="rachel.torres@yahoo.com" phone="512-555-0102" county="Bexar" time="2025-12-19 19:23:12.0717" network-id="1"></join-form->
<join-form- schema="org_1" id="3" fname="David" lname="Nguyen" email="david.nguyen@gmail.com" phone="830-555-0103" county="Comal" time="2026-01-06 16:55:29.0288" network-id="1"></join-form->
<join-form- schema="org_1" id="4" fname="Emily" lname="Sanders" email="emily.sanders@outlook.com" phone="210-555-0104" county="Hays" time="2026-01-07 17:14:01.0711" network-id="1"></join-form->
<contact-form- schema="org_1" id="1" fname="Marcus" lname="Webb" email="marcus.webb@gmail.com" phone="512-555-0201" county="Comal" message="Interested in volunteering at upcoming events." time="2025-12-29 13:20:28.0157" network-id="1"></contact-form->
<contact-form- schema="org_1" id="2" fname="Sandra" lname="Holloway" email="sandra.holloway@gmail.com" phone="830-555-0202" county="Comal" message="Would love to connect with your organization." time="2025-12-30 22:10:24.0971" network-id="1"></contact-form->
<contact-form- schema="org_1" id="3" fname="Robert" lname="Finley" email="robert.finley@gmail.com" phone="210-555-0203" county="Comal" message="Looking forward to getting more involved locally." time="2026-01-10 21:23:51.0073" network-id="1"></contact-form->
<contact-form- schema="org_1" id="4" fname="Barbara" lname="Crane" email="barbara.crane@outlook.com" phone="512-555-0204" county="Comal" message="Please reach out regarding the next meeting schedule." time="2026-01-10 21:23:54.0841" network-id="1"></contact-form->
</forms->

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

View File

@@ -0,0 +1,29 @@
<svg width="534" height="534" viewBox="0 0 534 534" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="9" height="534" fill="black"/>
<rect x="21" width="9" height="534" fill="black"/>
<rect x="42" width="9" height="534" fill="black"/>
<rect x="63" width="9" height="534" fill="black"/>
<rect x="84" width="9" height="534" fill="black"/>
<rect x="105" width="9" height="534" fill="black"/>
<rect x="126" width="9" height="534" fill="black"/>
<rect x="147" width="9" height="534" fill="black"/>
<rect x="168" width="9" height="534" fill="black"/>
<rect x="189" width="9" height="534" fill="black"/>
<rect x="210" width="9" height="534" fill="black"/>
<rect x="231" width="9" height="534" fill="black"/>
<circle cx="266.5" cy="271.5" r="127.5" fill="#FF0000"/>
<rect x="273" width="9" height="534" fill="black"/>
<rect x="294" width="9" height="534" fill="black"/>
<rect x="315" width="9" height="534" fill="black"/>
<rect x="336" width="9" height="534" fill="black"/>
<rect x="357" width="9" height="534" fill="black"/>
<rect x="378" width="9" height="534" fill="black"/>
<rect x="399" width="9" height="534" fill="black"/>
<rect x="420" width="9" height="534" fill="black"/>
<rect x="441" width="9" height="534" fill="black"/>
<rect x="462" width="9" height="534" fill="black"/>
<rect x="483" width="9" height="534" fill="black"/>
<rect x="504" width="9" height="534" fill="black"/>
<rect x="525" width="9" height="534" fill="black"/>
<rect x="252" width="9" height="534" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

View File

@@ -0,0 +1,460 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Captured Sun</title>
<link rel="icon" href="/_/logo.svg">
<style>
@font-face {
font-family: 'IMFell';
src: url('/_/IMFell/IMFell.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
*, *::before, *::after { box-sizing: border-box; }
body {
overflow: hidden;
margin: 0;
padding: 0;
background: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
img {
user-select: none;
-webkit-user-drag: none;
pointer-events: none;
}
a:visited {
color: inherit;
}
@keyframes rotate-stars {
0% { transform: rotate(0deg) translateX(0); }
25% { transform: rotate(90deg) translateX(50px); }
50% { transform: rotate(180deg) translateX(0); }
75% { transform: rotate(270deg) translateX(-50px); }
100% { transform: rotate(360deg) translateX(0); }
}
.stars {
display: block;
height: auto;
position: absolute;
animation: rotate-stars 950s linear infinite;
transform-origin: center center;
top: -550px;
width: 1600px;
z-index: 0;
}
.moon {
position: fixed;
z-index: 2;
width: 100vw;
left: 0px;
bottom: 0px; /* seam overlap — tweak to taste */
}
.atlas {
position: fixed;
left: 50%;
bottom: -100px;
transform: translateX(-50%);
width: 85vw;
height: auto;
z-index: 3;
pointer-events: none;
}
.atlas img { display: block; width: 100%; height: auto; }
.nav {
position: fixed;
bottom: 440px; left: 0; right: 0;
display: flex; justify-content: center; gap: 18px;
z-index: 4;
}
.btn {
padding: 12px 20px;
border-radius: 11px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
font-family: 'IMFell';
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow:
inset 0 0.5px 0 rgba(255, 255, 255, 0.35),
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
0 8px 24px rgba(0, 0, 0, 0.25);
transition: transform 120ms ease, box-shadow 120ms ease;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-red {
background: rgba(220, 38, 38, 0.30);
color: #d06868;
border: 0.5px solid rgba(254, 202, 202, 0.05);
}
.btn-orange {
background: rgba(234, 88, 12, 0.30);
color: #d79d5a;
border: 0.5px solid rgba(254, 215, 170, 0.05);
}
.btn-yellow {
background: rgba(234, 179, 8, 0.30);
color: #d0c04b;
border: 0.5px solid rgba(254, 240, 138, 0.05);
}
.menu {
position: fixed;
border-radius: 11px;
overflow: hidden;
z-index: 10;
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow:
inset 0 0.5px 0 rgba(255, 255, 255, 0.35),
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
0 8px 24px rgba(0, 0, 0, 0.25);
transition:
top 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
left 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
width 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
height 460ms cubic-bezier(0.32, 0.72, 0.20, 1),
border-radius 460ms cubic-bezier(0.32, 0.72, 0.20, 1);
visibility: hidden;
pointer-events: none;
font-family: 'IMFell';
}
.menu.is-active { visibility: visible; pointer-events: auto; }
.menu-red { background: rgba(220, 38, 38, 0.30); color: #fab2b2; border: 0.5px solid rgba(254, 202, 202, 0.05); }
.menu-orange { background: rgba(234, 88, 12, 0.30); color: #d79d5a; border: 0.5px solid rgba(254, 215, 170, 0.05); }
.menu-yellow { background: rgba(234, 179, 8, 0.30); color: #d0c04b; border: 0.5px solid rgba(254, 240, 138, 0.05); }
.menu-close {
position: absolute;
top: 18px; right: 18px;
width: 36px; height: 36px;
border-radius: 50%;
border: 0.5px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.20);
color: inherit;
font-size: 22px;
line-height: 1;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
opacity: 0;
transition: opacity 180ms ease, background 150ms ease;
z-index: 2;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
.menu-close:hover { background: rgba(0, 0, 0, 0.35); }
.menu.is-open .menu-close { opacity: 1; }
.menu-inner {
padding: 72px 56px 56px;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms ease, transform 220ms ease;
height: 100%;
overflow-y: auto;
color: inherit;
}
.menu.is-open .menu-inner {
opacity: 1;
transform: translateY(0);
transition-delay: 280ms;
}
.menu h2 {
margin: 0 0 24px;
font-size: 36px;
font-weight: 400;
letter-spacing: 0.02em;
}
.menu p {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 16px;
line-height: 1.65;
margin: 0 0 16px;
max-width: 62ch;
}
.link-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.link-list a {
display: block;
padding: 16px 18px;
background: rgba(0, 0, 0, 0.15);
border: 0.5px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
color: inherit;
text-decoration: none;
transition: background 150ms ease, transform 150ms ease;
}
.link-list a:hover { background: rgba(0, 0, 0, 0.30); transform: translateY(-1px); }
.link-list .label { display: block; font-size: 17px; }
.link-list .desc {
display: block;
font-size: 13px;
opacity: 0.7;
margin-top: 4px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
.contact-form {
display: grid;
gap: 16px;
max-width: 560px;
}
.contact-form label {
display: grid;
gap: 6px;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.85;
}
.contact-form input,
.contact-form textarea {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 15px;
color: #f0e6c8;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.50);
border: 0.5px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
outline: none;
transition: border-color 150ms ease, background 150ms ease;
}
.contact-form input::placeholder,
.contact-form textarea::placeholder { color: rgba(240, 230, 200, 0.35); }
.contact-form input:focus,
.contact-form textarea:focus {
border-color: rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.65);
}
.contact-form textarea { resize: vertical; min-height: 140px; }
.contact-form .submit {
justify-self: end;
margin-top: 4px;
padding: 12px 26px;
border-radius: 10px;
border: 0.5px solid rgba(254, 240, 138, 0.10);
background: rgba(234, 179, 8, 0.40);
color: inherit;
font-family: 'IMFell';
font-size: 16px;
letter-spacing: 0.04em;
cursor: pointer;
transition: transform 120ms ease, background 120ms ease;
}
.contact-form .submit:hover { background: rgba(234, 179, 8, 0.55); transform: translateY(-1px); }
@media (max-width: 768px) {
.space {
position: absolute;
transform: translate(-50%, 0);
top: 0px;
display:none;
width: 1200px;
}
.moon {
display: none;
}
.stars {
display: none;
}
.atlas {
top: -200px;
width: 1000px;
}
.nav {
bottom: 1vh
}
.menu-red {
color: rgb(255, 255, 255) !important
}
.menu-inner { padding: 64px 28px 28px; }
.menu h2 { font-size: 28px; }
}
</style>
</head>
<body style="cursor: default">
<img class="stars" src="/_/stars.png" alt="">
<img class="moon" src="/_/moon.png" alt="">
<picture class="atlas">
<source media="(max-width: 768px)" srcset="/_/atlasmobile.png">
<img src="/_/atlas.webp" alt="">
</picture>
<nav class="nav">
<button class="btn btn-red">About</button>
<!-- <button class="btn btn-orange">Our Work</button> -->
<button class="btn btn-orange">Contact Us</button>
</nav>
<div class="menu menu-red" id="menu-about">
<button class="menu-close" aria-label="Close">X</button>
<div class="menu-inner">
<h2>About</h2>
<p><i>All energy comes from the sun. To think is to capture, freeze, and reflect it.</i></p>
<p>Captured Sun was founded in 2022, to answer a simple question: what would the operating system look like if it was rearranged for the modern day?</p>
<p>The answer we have found is <a style="color: inherit;" href="https://frm.so" target="_blank">Forum</a>: the Community Operating System.</p>
<p>In concert with Forum, we are happy to accept certain consulting jobs with people or organizations that need custom solutions. If that's you, feel free to reach out.</p>
</div>
</div>
<div class="menu menu-orange" id="menu-work">
<button class="menu-close" aria-label="Close">X</button>
<div class="menu-inner">
<h2>Our Work</h2>
<ul class="link-list">
<li><a href="https://apple.com" target="_blank" rel="noopener"><span class="label">Apple</span><span class="desc">Product launch microsite</span></a></li>
<li><a href="https://vercel.com" target="_blank" rel="noopener"><span class="label">Vercel</span><span class="desc">Edge runtime documentation</span></a></li>
<li><a href="https://linear.app" target="_blank" rel="noopener"><span class="label">Linear</span><span class="desc">Marketing site redesign</span></a></li>
<li><a href="https://figma.com" target="_blank" rel="noopener"><span class="label">Figma</span><span class="desc">Plugin gallery</span></a></li>
<li><a href="https://stripe.com" target="_blank" rel="noopener"><span class="label">Stripe</span><span class="desc">Developer onboarding flow</span></a></li>
<li><a href="https://railway.com" target="_blank" rel="noopener"><span class="label">Railway</span><span class="desc">Pricing page experiments</span></a></li>
<li><a href="https://anthropic.com" target="_blank" rel="noopener"><span class="label">Anthropic</span><span class="desc">Research publication template</span></a></li>
<li><a href="https://notion.so" target="_blank" rel="noopener"><span class="label">Notion</span><span class="desc">Template marketplace</span></a></li>
</ul>
</div>
</div>
<div class="menu menu-orange" id="menu-contact">
<button class="menu-close" aria-label="Close"><span>X</span></button>
<div class="menu-inner">
<h2>Contact Us</h2>
<form class="contact-form" novalidate onsubmit="return handleContactSubmit(event, this)">
<label>Name <input type="text" name="name" placeholder="Your name" required></label>
<label>Email <input type="email" name="email" placeholder="you@example.com"></label>
<label>Phone <input type="tel" name="phone" placeholder="+1 555 123 4567"></label>
<label>Message <textarea name="message" placeholder="Tell us about your project..." required></textarea></label>
<button class="submit" type="submit">Send Message</button>
</form>
</div>
</div>
<script>
function handleContactSubmit(event, form) {
event.preventDefault();
var email = form.email.value.trim();
var phone = form.phone.value.trim();
if (!email && !phone) {
form.email.setCustomValidity('Provide an email or a phone number.');
form.email.reportValidity();
return false;
}
form.email.setCustomValidity('');
if (!form.checkValidity()) { form.reportValidity(); return false; }
form.reset();
return false;
}
(function () {
var pairs = [
{ btn: document.querySelector('.btn-red'), menu: document.getElementById('menu-about') },
{ btn: document.querySelector('.btn-orange'), menu: document.getElementById('menu-contact') }
];
var active = null;
var FINAL_RADIUS = '18px';
function applyRect(menu, rect) {
menu.style.top = rect.top + 'px';
menu.style.left = rect.left + 'px';
menu.style.width = rect.width + 'px';
menu.style.height = rect.height + 'px';
menu.style.borderRadius = '11px';
}
function applyFinal(menu) {
menu.style.top = '10vh';
menu.style.left = '10vw';
menu.style.width = '80vw';
menu.style.height = '80vh';
menu.style.borderRadius = FINAL_RADIUS;
}
function openMenu(pair) {
if (active) closeMenu();
var btn = pair.btn, menu = pair.menu;
if (menu._closeTimer) { clearTimeout(menu._closeTimer); menu._closeTimer = null; }
var rect = btn.getBoundingClientRect();
menu.style.transition = 'none';
applyRect(menu, rect);
menu.classList.add('is-active');
void menu.offsetHeight;
menu.style.transition = '';
btn.style.visibility = 'hidden';
applyFinal(menu);
menu.classList.add('is-open');
active = pair;
}
function closeMenu() {
if (!active) return;
var btn = active.btn, menu = active.menu;
var rect = btn.getBoundingClientRect();
applyRect(menu, rect);
menu.classList.remove('is-open');
if (menu._closeTimer) clearTimeout(menu._closeTimer);
menu._closeTimer = setTimeout(function () {
menu.classList.remove('is-active');
btn.style.visibility = '';
menu._closeTimer = null;
}, 460);
active = null;
}
pairs.forEach(function (pair) {
pair.btn.addEventListener('click', function (e) {
e.stopPropagation();
openMenu(pair);
});
pair.menu.addEventListener('click', function (e) { e.stopPropagation(); });
pair.menu.querySelector('.menu-close').addEventListener('click', function (e) {
e.stopPropagation();
closeMenu();
});
});
document.addEventListener('click', function () {
if (active) closeMenu();
});
window.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && active) closeMenu();
});
window.addEventListener('resize', function () {
if (!active) return;
applyFinal(active.menu);
});
})();
</script>
</body>
</html>

5
priv/schema.sql Normal file
View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
text TEXT NOT NULL,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

496
priv/ui/admin.html Normal file
View File

@@ -0,0 +1,496 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>admin</title>
<link rel="icon" href="/admin/graphyellow.svg">
<style>
body {
background: #850000;
color: #FEBA7D;
font: 12px ui-monospace, monospace;
margin: 1rem;
}
.tabs { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.tabs button {
color: #FEBA7D; background: none; border: 1px solid transparent; font: inherit;
cursor: pointer; padding: 4px 10px; border-radius: 3px;
}
.tabs button:hover { background: #9D1A12; border-color: #C7643F; }
.tabs button.active {
font-weight: bold; color: #850000; background: #FEBA7D;
border-color: #FEBA7D; cursor: default;
}
.toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
.toolbar button {
color: #FEBA7D; background: none; border: none; font: inherit;
cursor: pointer; padding: 0 2px;
}
.toolbar button:hover { color: #FEBA7D; text-decoration: underline; }
.toolbar button.active { font-weight: bold; color: #FEBA7D; cursor: default; }
.toolbar input {
background: #6C0000; color: #FEBA7D; font: inherit;
padding: 2px 6px; border: 1px solid #C7643F;
border-radius: 3px; width: 16rem;
}
.toolbar input::placeholder { color: #E49768; }
table { border-collapse: collapse; width: 100%; }
th, td { padding: 4px 8px; border-bottom: 1px solid rgba(254, 232, 200, 0.2); text-align: left; vertical-align: top; }
td { color: #FEBA7D; }
th { background: #6C0000; color: #FEBA7D; position: sticky; top: 0; }
tr:hover { background: #9D1A12; }
.num { text-align: right; }
.name, .mod { color: #ffe1c6; }
#status { margin-left: auto; color: #FEBA7D; }
.disclosure {
display: inline-block; width: 1em; cursor: pointer;
color: #FEBA7D; user-select: none;
}
.disclosure.leaf { cursor: default; visibility: hidden; }
.pid-cell { white-space: pre; }
section[hidden] { display: none; }
</style>
<script src="https://frm.so/_/code/quill.js"></script>
</head>
<body>
<nav class="tabs">
<button id="tab-processes" class="active">processes</button>
<button id="tab-modules">modules</button>
<button id="tab-logs">logs</button>
<button id="tab-vm-memory">vm memory</button>
<span id="status">connecting…</span>
</nav>
<section id="panel-processes">
<div class="toolbar">
<button id="p-mine" class="active">mine</button>
<button id="p-all">all</button>
<span>|</span>
<button id="p-expand">expand all</button>
<button id="p-collapse">collapse all</button>
</div>
<table>
<thead><tr>
<th>pid</th><th>name</th><th>initial call</th>
<th class="num">memory (KB)</th><th class="num">tree memory (KB)</th>
<th class="num">msgs</th><th>status</th>
</tr></thead>
<tbody id="p-rows"></tbody>
</table>
</section>
<section id="panel-modules" hidden>
<div class="toolbar">
<button id="m-mine" class="active">mine</button>
<button id="m-all">all</button>
<input id="m-filter" placeholder="filter…" autocomplete="off">
<button id="m-refresh">refresh</button>
</div>
<table>
<thead><tr>
<th>module</th><th>app</th><th>source</th>
</tr></thead>
<tbody id="m-rows"></tbody>
</table>
</section>
<section id="panel-logs" hidden>
<div class="toolbar">
<button id="l-refresh">refresh</button>
</div>
<table>
<thead><tr>
<th>time (Chicago)</th><th>source ip</th><th>host</th><th>method</th><th>path</th>
<th class="num">status</th><th class="num">duration ms</th>
</tr></thead>
<tbody id="l-rows"></tbody>
</table>
</section>
<section id="panel-vm-memory" hidden>
<div class="toolbar">
<button id="v-refresh">refresh</button>
</div>
<table>
<thead><tr>
<th>category</th><th class="num">bytes</th>
<th class="num">KB</th><th class="num">MB</th>
</tr></thead>
<tbody id="v-rows"></tbody>
</table>
</section>
<script>
// ── shared infrastructure ──────────────────────────────────────
const status = document.getElementById("status");
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/admin/ws";
const ws = new WebSocket(url);
// Per-message-type dispatch so each panel registers its own handler.
const handlers = {};
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
const h = handlers[msg.type];
if (h) h(msg);
});
function send(obj) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function addCell(tr, text, cls) {
const td = document.createElement("td");
if (cls) td.className = cls;
td.textContent = text;
tr.appendChild(td);
}
// ── tabs ───────────────────────────────────────────────────────
const tabs = {
processes: { btn: document.getElementById("tab-processes"), panel: document.getElementById("panel-processes") },
modules: { btn: document.getElementById("tab-modules"), panel: document.getElementById("panel-modules") },
logs: { btn: document.getElementById("tab-logs"), panel: document.getElementById("panel-logs") },
vmMemory: { btn: document.getElementById("tab-vm-memory"), panel: document.getElementById("panel-vm-memory") },
};
const tabRoutes = {
processes: "processes",
modules: "modules",
logs: "logs",
vmMemory: "vm-memory",
};
let active = "processes";
function tabFromLocation() {
const segment = location.pathname.split("/").filter(Boolean)[1];
return Object.keys(tabRoutes).find(name => tabRoutes[name] === segment) || "processes";
}
function setAdminPath(name, mode) {
const nextPath = "/admin/" + tabRoutes[name];
if (location.pathname === nextPath) return;
const nextUrl = nextPath + location.search + location.hash;
if (mode === "replace") history.replaceState({tab: name}, "", nextUrl);
else history.pushState({tab: name}, "", nextUrl);
}
function activate(name, mode) {
active = name;
setAdminPath(name, mode);
for (const [k, t] of Object.entries(tabs)) {
t.btn.classList.toggle("active", k === name);
t.panel.hidden = k !== name;
}
if (name === "modules" && !mLoaded) pollModules();
if (name === "logs" && !lLoaded) pollLogs();
if (name === "vmMemory" && !vLoaded) pollVmMemory();
}
tabs.processes.btn.addEventListener("click", () => activate("processes"));
tabs.modules.btn.addEventListener("click", () => activate("modules"));
tabs.logs.btn.addEventListener("click", () => activate("logs"));
tabs.vmMemory.btn.addEventListener("click", () => activate("vmMemory"));
window.addEventListener("popstate", () => activate(tabFromLocation(), "replace"));
// ── processes panel ────────────────────────────────────────────
const pBody = document.getElementById("p-rows");
const pAll = document.getElementById("p-all");
const pMine = document.getElementById("p-mine");
const pExpand = document.getElementById("p-expand");
const pCollapse = document.getElementById("p-collapse");
let pFilterMine = $("#p-mine").classList.contains("active");
let pLastRows = [];
const pCollapsed = new Set();
const pAutoCollapsed = new Set();
const pSeenLarge = new Set();
function pSetFilter(mine) {
pFilterMine = mine;
pAll.classList.toggle("active", !mine);
pMine.classList.toggle("active", mine);
pRender();
}
pAll.addEventListener("click", () => pSetFilter(false));
pMine.addEventListener("click", () => pSetFilter(true));
pExpand.addEventListener("click", () => {
pCollapsed.clear();
pAutoCollapsed.clear();
for (const pid of pLargeGroupPids()) pSeenLarge.add(pid);
pRender();
});
pCollapse.addEventListener("click", () => {
const haveChildren = new Set(pLastRows.map(r => r.parent).filter(Boolean));
const topPid = pPrimaryRootProcessPid();
pCollapsed.clear();
pAutoCollapsed.clear();
for (const pid of haveChildren) {
if (pid !== topPid) pCollapsed.add(pid);
}
pRender();
});
function pBuildTree(rows) {
const byPid = new Map();
for (const r of rows) byPid.set(r.pid, Object.assign({}, r, {children: []}));
const roots = [];
for (const node of byPid.values()) {
const parent = node.parent && byPid.get(node.parent);
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
function pRender() {
const rows = pFilterMine ? pLastRows.filter(r => r.mine) : pLastRows;
const tree = pBuildTree(rows);
const mineN = pLastRows.filter(r => r.mine).length;
pAll.textContent = "all (" + pLastRows.length + ")";
pMine.textContent = "mine (" + mineN + ")";
const frag = document.createDocumentFragment();
for (const node of tree) pRenderNode(node, 0, frag);
pBody.replaceChildren(frag);
}
function pRenderNode(node, depth, frag) {
const tr = document.createElement("tr");
if (node.mine) tr.className = "mine";
const pidTd = document.createElement("td");
pidTd.className = "pid-cell";
pidTd.style.paddingLeft = (8 + depth * 16) + "px";
const disc = document.createElement("span");
disc.className = "disclosure" + (node.children.length ? "" : " leaf");
disc.textContent =
node.children.length === 0 ? "·" :
pCollapsed.has(node.pid) ? "▸" : "▾";
if (node.children.length) {
disc.addEventListener("click", () => {
pSeenLarge.add(node.pid);
pAutoCollapsed.delete(node.pid);
if (pCollapsed.has(node.pid)) pCollapsed.delete(node.pid);
else pCollapsed.add(node.pid);
pRender();
});
}
pidTd.appendChild(disc);
pidTd.appendChild(document.createTextNode(" " + node.pid));
tr.appendChild(pidTd);
addCell(tr, node.name || "-", node.name ? "name" : "");
addCell(tr, node.initial_call);
addCell(tr, node.memory_kb, "num");
addCell(tr, node.tree_memory_kb || node.memory_kb, "num");
addCell(tr, node.msgs, "num");
addCell(tr, node.status);
frag.appendChild(tr);
if (!pCollapsed.has(node.pid)) {
for (const child of node.children) pRenderNode(child, depth + 1, frag);
}
}
function pollProcesses() { send({type: "list_processes"}); }
handlers["processes"] = (msg) => {
pLastRows = msg.rows;
pAutoCollapseLargeGroups();
pRender();
};
function pLargeGroupPids() {
const large = new Set();
const visit = (node) => {
if (node.children.length > 10) large.add(node.pid);
for (const child of node.children) visit(child);
};
for (const node of pBuildTree(pLastRows)) visit(node);
return large;
}
function pPrimaryRootProcessPid() {
const supervisor = pLastRows.find(r => r.name === "Forum.Supervisor");
if (supervisor) return supervisor.pid;
const roots = pBuildTree(pLastRows);
return roots.length ? roots[0].pid : null;
}
function pAutoCollapseLargeGroups() {
const large = pLargeGroupPids();
const topPid = pPrimaryRootProcessPid();
if (topPid) {
large.delete(topPid);
pCollapsed.delete(topPid);
pAutoCollapsed.delete(topPid);
pSeenLarge.add(topPid);
}
for (const pid of pAutoCollapsed) {
if (!large.has(pid)) {
pCollapsed.delete(pid);
pAutoCollapsed.delete(pid);
}
}
for (const pid of large) {
if (!pSeenLarge.has(pid)) {
pCollapsed.add(pid);
pAutoCollapsed.add(pid);
pSeenLarge.add(pid);
}
}
}
// ── modules panel ──────────────────────────────────────────────
const mBody = document.getElementById("m-rows");
const mAll = document.getElementById("m-all");
const mMine = document.getElementById("m-mine");
const mFilter = document.getElementById("m-filter");
const mRefresh = document.getElementById("m-refresh");
let mFilterMine = $("#m-mine").classList.contains("active");
let mNeedle = "";
let mLastRows = [];
let mLoaded = false;
function mSetFilter(mine) {
mFilterMine = mine;
mAll.classList.toggle("active", !mine);
mMine.classList.toggle("active", mine);
mRender();
}
mAll.addEventListener("click", () => mSetFilter(false));
mMine.addEventListener("click", () => mSetFilter(true));
mFilter.addEventListener("input", () => { mNeedle = mFilter.value.toLowerCase(); mRender(); });
mRefresh.addEventListener("click", () => pollModules());
function mRender() {
let rows = mLastRows;
if (mFilterMine) rows = rows.filter(r => r.mine);
if (mNeedle) {
rows = rows.filter(r =>
r.module.toLowerCase().includes(mNeedle) ||
(r.source && r.source.toLowerCase().includes(mNeedle)) ||
(r.app && r.app.toLowerCase().includes(mNeedle))
);
}
const mineN = mLastRows.filter(r => r.mine).length;
mAll.textContent = "all (" + mLastRows.length + ")";
mMine.textContent = "mine (" + mineN + ")";
const frag = document.createDocumentFragment();
for (const r of rows) {
const tr = document.createElement("tr");
if (r.mine) tr.className = "mine";
addCell(tr, r.module, "mod");
addCell(tr, r.app || "-");
addCell(tr, r.source || "(no source)", "src");
frag.appendChild(tr);
}
mBody.replaceChildren(frag);
}
function pollModules() { send({type: "list_modules"}); }
handlers["modules"] = (msg) => {
mLastRows = msg.rows;
mLoaded = true;
mRender();
};
// ── logs panel ─────────────────────────────────────────────────
const lBody = document.getElementById("l-rows");
const lRefresh = document.getElementById("l-refresh");
let lLoaded = false;
const chicagoTimeParts = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Chicago",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true
});
function pollLogs() { send({type: "list_logs"}); }
function formatLogTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const parts = Object.fromEntries(
chicagoTimeParts.formatToParts(date).map(part => [part.type, part.value])
);
return `${parts.month}.${parts.day} ${parts.hour}:${parts.minute}:${parts.second}${parts.dayPeriod.toLowerCase()}`;
}
lRefresh.addEventListener("click", () => pollLogs());
handlers["logs"] = (msg) => {
lLoaded = true;
const frag = document.createDocumentFragment();
for (const r of msg.rows) {
const tr = document.createElement("tr");
addCell(tr, formatLogTime(r.time));
addCell(tr, r.source_ip || "");
addCell(tr, r.host || "");
addCell(tr, r.method || "");
addCell(tr, (r.path || "") + (r.query_string ? "?" + r.query_string : ""));
addCell(tr, r.status || "", "num");
addCell(tr, r.duration_ms || "", "num");
frag.appendChild(tr);
}
lBody.replaceChildren(frag);
};
// ── VM memory panel ────────────────────────────────────────────
const vBody = document.getElementById("v-rows");
const vRefresh = document.getElementById("v-refresh");
let vLoaded = false;
function pollVmMemory() { send({type: "list_vm_memory"}); }
vRefresh.addEventListener("click", () => pollVmMemory());
handlers["vm_memory"] = (msg) => {
vLoaded = true;
const frag = document.createDocumentFragment();
for (const r of msg.rows) {
const tr = document.createElement("tr");
addCell(tr, r.category || "");
addCell(tr, r.bytes || "", "num");
addCell(tr, r.kb || "", "num");
addCell(tr, r.mb || "", "num");
frag.appendChild(tr);
}
vBody.replaceChildren(frag);
};
// ── boot ───────────────────────────────────────────────────────
activate(tabFromLocation(), "replace");
ws.addEventListener("open", () => {
status.textContent = "connected — processes auto-refresh 5s";
pollProcesses();
if (active === "modules") pollModules();
if (active === "logs") pollLogs();
if (active === "vmMemory") pollVmMemory();
// Processes keep polling whether or not the tab is visible — keeps
// the view fresh when the user switches back. Modules load on
// first activation and via the refresh button.
setInterval(pollProcesses, 5000);
});
ws.addEventListener("close", () => { status.textContent = "disconnected"; });
ws.addEventListener("error", () => { status.textContent = "error"; });
</script>
</body>
</html>

490
priv/ui/admin.pug Normal file
View File

@@ -0,0 +1,490 @@
doctype html
html(lang="en")
head
meta(charset="utf-8")
title admin
link(rel="icon", href="/graphyellow.svg")
style.
body {
background: #850000;
color: #FEBA7D;
font: 12px ui-monospace, monospace;
margin: 1rem;
}
.tabs { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.tabs button {
color: #FEBA7D; background: none; border: 1px solid transparent; font: inherit;
cursor: pointer; padding: 4px 10px; border-radius: 3px;
}
.tabs button:hover { background: #9D1A12; border-color: #C7643F; }
.tabs button.active {
font-weight: bold; color: #850000; background: #FEBA7D;
border-color: #FEBA7D; cursor: default;
}
.toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
.toolbar button {
color: #FEBA7D; background: none; border: none; font: inherit;
cursor: pointer; padding: 0 2px;
}
.toolbar button:hover { color: #FEBA7D; text-decoration: underline; }
.toolbar button.active { font-weight: bold; color: #FEBA7D; cursor: default; }
.toolbar input {
background: #6C0000; color: #FEBA7D; font: inherit;
padding: 2px 6px; border: 1px solid #C7643F;
border-radius: 3px; width: 16rem;
}
.toolbar input::placeholder { color: #E49768; }
table { border-collapse: collapse; width: 100%; }
th, td { padding: 4px 8px; border-bottom: 0.5px solid rgba(254, 232, 200, 0.2); text-align: left; vertical-align: top; }
td { color: #5C0000; }
th { background: #6C0000; color: #FEBA7D; position: sticky; top: 0; }
tr:hover { background: #9D1A12; }
.num { text-align: right; }
.name, .mod { color: #FEBA7D; }
.src { color: #5C0000; }
#status { margin-left: auto; color: #FEBA7D; }
.disclosure {
display: inline-block; width: 1em; cursor: pointer;
color: #FEBA7D; user-select: none;
}
.disclosure.leaf { cursor: default; visibility: hidden; }
.pid-cell { white-space: pre; }
section[hidden] { display: none; }
script(src="https://frm.so/_/code/quill.js")
body
nav.tabs
button#tab-processes.active processes
button#tab-modules modules
button#tab-logs logs
button#tab-vm-memory vm memory
span#status connecting…
section#panel-processes
.toolbar
button#p-mine.active mine
button#p-all all
span |
button#p-expand expand all
button#p-collapse collapse all
table
thead
tr
th pid
th name
th initial call
th.num memory (KB)
th.num tree memory (KB)
th.num msgs
th status
tbody#p-rows
section#panel-modules(hidden)
.toolbar
button#m-mine.active mine
button#m-all all
input#m-filter(placeholder="filter…", autocomplete="off")
button#m-refresh refresh
table
thead
tr
th module
th app
th source
tbody#m-rows
section#panel-logs(hidden)
.toolbar
button#l-refresh refresh
table
thead
tr
th time (Chicago)
th source ip
th host
th method
th path
th.num status
th.num duration ms
tbody#l-rows
section#panel-vm-memory(hidden)
.toolbar
button#v-refresh refresh
table
thead
tr
th category
th.num bytes
th.num KB
th.num MB
tbody#v-rows
script.
// ── shared infrastructure ──────────────────────────────────────
const status = document.getElementById("status");
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/admin/ws";
const ws = new WebSocket(url);
// Per-message-type dispatch so each panel registers its own handler.
const handlers = {};
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
const h = handlers[msg.type];
if (h) h(msg);
});
function send(obj) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function addCell(tr, text, cls) {
const td = document.createElement("td");
if (cls) td.className = cls;
td.textContent = text;
tr.appendChild(td);
}
// ── tabs ───────────────────────────────────────────────────────
const tabs = {
processes: { btn: document.getElementById("tab-processes"), panel: document.getElementById("panel-processes") },
modules: { btn: document.getElementById("tab-modules"), panel: document.getElementById("panel-modules") },
logs: { btn: document.getElementById("tab-logs"), panel: document.getElementById("panel-logs") },
vmMemory: { btn: document.getElementById("tab-vm-memory"), panel: document.getElementById("panel-vm-memory") },
};
const tabRoutes = {
processes: "processes",
modules: "modules",
logs: "logs",
vmMemory: "vm-memory",
};
let active = "processes";
function tabFromLocation() {
const segment = location.pathname.split("/").filter(Boolean)[1];
return Object.keys(tabRoutes).find(name => tabRoutes[name] === segment) || "processes";
}
function setAdminPath(name, mode) {
const nextPath = "/admin/" + tabRoutes[name];
if (location.pathname === nextPath) return;
const nextUrl = nextPath + location.search + location.hash;
if (mode === "replace") history.replaceState({tab: name}, "", nextUrl);
else history.pushState({tab: name}, "", nextUrl);
}
function activate(name, mode) {
active = name;
setAdminPath(name, mode);
for (const [k, t] of Object.entries(tabs)) {
t.btn.classList.toggle("active", k === name);
t.panel.hidden = k !== name;
}
if (name === "modules" && !mLoaded) pollModules();
if (name === "logs" && !lLoaded) pollLogs();
if (name === "vmMemory" && !vLoaded) pollVmMemory();
}
tabs.processes.btn.addEventListener("click", () => activate("processes"));
tabs.modules.btn.addEventListener("click", () => activate("modules"));
tabs.logs.btn.addEventListener("click", () => activate("logs"));
tabs.vmMemory.btn.addEventListener("click", () => activate("vmMemory"));
window.addEventListener("popstate", () => activate(tabFromLocation(), "replace"));
// ── processes panel ────────────────────────────────────────────
const pBody = document.getElementById("p-rows");
const pAll = document.getElementById("p-all");
const pMine = document.getElementById("p-mine");
const pExpand = document.getElementById("p-expand");
const pCollapse = document.getElementById("p-collapse");
let pFilterMine = $("#p-mine").classList.contains("active");
let pLastRows = [];
const pCollapsed = new Set();
const pAutoCollapsed = new Set();
const pSeenLarge = new Set();
function pSetFilter(mine) {
pFilterMine = mine;
pAll.classList.toggle("active", !mine);
pMine.classList.toggle("active", mine);
pRender();
}
pAll.addEventListener("click", () => pSetFilter(false));
pMine.addEventListener("click", () => pSetFilter(true));
pExpand.addEventListener("click", () => {
pCollapsed.clear();
pAutoCollapsed.clear();
for (const pid of pLargeGroupPids()) pSeenLarge.add(pid);
pRender();
});
pCollapse.addEventListener("click", () => {
const haveChildren = new Set(pLastRows.map(r => r.parent).filter(Boolean));
const topPid = pPrimaryRootProcessPid();
pCollapsed.clear();
pAutoCollapsed.clear();
for (const pid of haveChildren) {
if (pid !== topPid) pCollapsed.add(pid);
}
pRender();
});
function pBuildTree(rows) {
const byPid = new Map();
for (const r of rows) byPid.set(r.pid, Object.assign({}, r, {children: []}));
const roots = [];
for (const node of byPid.values()) {
const parent = node.parent && byPid.get(node.parent);
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
function pRender() {
const rows = pFilterMine ? pLastRows.filter(r => r.mine) : pLastRows;
const tree = pBuildTree(rows);
const mineN = pLastRows.filter(r => r.mine).length;
pAll.textContent = "all (" + pLastRows.length + ")";
pMine.textContent = "mine (" + mineN + ")";
const frag = document.createDocumentFragment();
for (const node of tree) pRenderNode(node, 0, frag);
pBody.replaceChildren(frag);
}
function pRenderNode(node, depth, frag) {
const tr = document.createElement("tr");
if (node.mine) tr.className = "mine";
const pidTd = document.createElement("td");
pidTd.className = "pid-cell";
pidTd.style.paddingLeft = (8 + depth * 16) + "px";
const disc = document.createElement("span");
disc.className = "disclosure" + (node.children.length ? "" : " leaf");
disc.textContent =
node.children.length === 0 ? "·" :
pCollapsed.has(node.pid) ? "▸" : "▾";
if (node.children.length) {
disc.addEventListener("click", () => {
pSeenLarge.add(node.pid);
pAutoCollapsed.delete(node.pid);
if (pCollapsed.has(node.pid)) pCollapsed.delete(node.pid);
else pCollapsed.add(node.pid);
pRender();
});
}
pidTd.appendChild(disc);
pidTd.appendChild(document.createTextNode(" " + node.pid));
tr.appendChild(pidTd);
addCell(tr, node.name || "-", node.name ? "name" : "");
addCell(tr, node.initial_call);
addCell(tr, node.memory_kb, "num");
addCell(tr, node.tree_memory_kb || node.memory_kb, "num");
addCell(tr, node.msgs, "num");
addCell(tr, node.status);
frag.appendChild(tr);
if (!pCollapsed.has(node.pid)) {
for (const child of node.children) pRenderNode(child, depth + 1, frag);
}
}
function pollProcesses() { send({type: "list_processes"}); }
handlers["processes"] = (msg) => {
pLastRows = msg.rows;
pAutoCollapseLargeGroups();
pRender();
};
function pLargeGroupPids() {
const large = new Set();
const visit = (node) => {
if (node.children.length > 10) large.add(node.pid);
for (const child of node.children) visit(child);
};
for (const node of pBuildTree(pLastRows)) visit(node);
return large;
}
function pPrimaryRootProcessPid() {
const supervisor = pLastRows.find(r => r.name === "Forum.Supervisor");
if (supervisor) return supervisor.pid;
const roots = pBuildTree(pLastRows);
return roots.length ? roots[0].pid : null;
}
function pAutoCollapseLargeGroups() {
const large = pLargeGroupPids();
const topPid = pPrimaryRootProcessPid();
if (topPid) {
large.delete(topPid);
pCollapsed.delete(topPid);
pAutoCollapsed.delete(topPid);
pSeenLarge.add(topPid);
}
for (const pid of pAutoCollapsed) {
if (!large.has(pid)) {
pCollapsed.delete(pid);
pAutoCollapsed.delete(pid);
}
}
for (const pid of large) {
if (!pSeenLarge.has(pid)) {
pCollapsed.add(pid);
pAutoCollapsed.add(pid);
pSeenLarge.add(pid);
}
}
}
// ── modules panel ──────────────────────────────────────────────
const mBody = document.getElementById("m-rows");
const mAll = document.getElementById("m-all");
const mMine = document.getElementById("m-mine");
const mFilter = document.getElementById("m-filter");
const mRefresh = document.getElementById("m-refresh");
let mFilterMine = $("#m-mine").classList.contains("active");
let mNeedle = "";
let mLastRows = [];
let mLoaded = false;
function mSetFilter(mine) {
mFilterMine = mine;
mAll.classList.toggle("active", !mine);
mMine.classList.toggle("active", mine);
mRender();
}
mAll.addEventListener("click", () => mSetFilter(false));
mMine.addEventListener("click", () => mSetFilter(true));
mFilter.addEventListener("input", () => { mNeedle = mFilter.value.toLowerCase(); mRender(); });
mRefresh.addEventListener("click", () => pollModules());
function mRender() {
let rows = mLastRows;
if (mFilterMine) rows = rows.filter(r => r.mine);
if (mNeedle) {
rows = rows.filter(r =>
r.module.toLowerCase().includes(mNeedle) ||
(r.source && r.source.toLowerCase().includes(mNeedle)) ||
(r.app && r.app.toLowerCase().includes(mNeedle))
);
}
const mineN = mLastRows.filter(r => r.mine).length;
mAll.textContent = "all (" + mLastRows.length + ")";
mMine.textContent = "mine (" + mineN + ")";
const frag = document.createDocumentFragment();
for (const r of rows) {
const tr = document.createElement("tr");
if (r.mine) tr.className = "mine";
addCell(tr, r.module, "mod");
addCell(tr, r.app || "-");
addCell(tr, r.source || "(no source)", "src");
frag.appendChild(tr);
}
mBody.replaceChildren(frag);
}
function pollModules() { send({type: "list_modules"}); }
handlers["modules"] = (msg) => {
mLastRows = msg.rows;
mLoaded = true;
mRender();
};
// ── logs panel ─────────────────────────────────────────────────
const lBody = document.getElementById("l-rows");
const lRefresh = document.getElementById("l-refresh");
let lLoaded = false;
const chicagoTime = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Chicago",
year: "numeric",
month: "short",
day: "2-digit",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short"
});
function pollLogs() { send({type: "list_logs"}); }
function formatLogTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return chicagoTime.format(date);
}
lRefresh.addEventListener("click", () => pollLogs());
handlers["logs"] = (msg) => {
lLoaded = true;
const frag = document.createDocumentFragment();
for (const r of msg.rows) {
const tr = document.createElement("tr");
addCell(tr, formatLogTime(r.time));
addCell(tr, r.source_ip || "");
addCell(tr, r.host || "");
addCell(tr, r.method || "");
addCell(tr, (r.path || "") + (r.query_string ? "?" + r.query_string : ""));
addCell(tr, r.status || "", "num");
addCell(tr, r.duration_ms || "", "num");
frag.appendChild(tr);
}
lBody.replaceChildren(frag);
};
// ── VM memory panel ────────────────────────────────────────────
const vBody = document.getElementById("v-rows");
const vRefresh = document.getElementById("v-refresh");
let vLoaded = false;
function pollVmMemory() { send({type: "list_vm_memory"}); }
vRefresh.addEventListener("click", () => pollVmMemory());
handlers["vm_memory"] = (msg) => {
vLoaded = true;
const frag = document.createDocumentFragment();
for (const r of msg.rows) {
const tr = document.createElement("tr");
addCell(tr, r.category || "");
addCell(tr, r.bytes || "", "num");
addCell(tr, r.kb || "", "num");
addCell(tr, r.mb || "", "num");
frag.appendChild(tr);
}
vBody.replaceChildren(frag);
};
// ── boot ───────────────────────────────────────────────────────
activate(tabFromLocation(), "replace");
ws.addEventListener("open", () => {
status.textContent = "connected — processes auto-refresh 5s";
pollProcesses();
if (active === "modules") pollModules();
if (active === "logs") pollLogs();
if (active === "vmMemory") pollVmMemory();
// Processes keep polling whether or not the tab is visible — keeps
// the view fresh when the user switches back. Modules load on
// first activation and via the refresh button.
setInterval(pollProcesses, 5000);
});
ws.addEventListener("close", () => { status.textContent = "disconnected"; });
ws.addEventListener("error", () => { status.textContent = "error"; });

93
priv/ui/desktop.html Normal file
View File

@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>forum</title>
<style>
body { font: 14px ui-monospace, monospace; margin: 2rem; background: #f7f7f4; color: #171717; }
#status { color: #888; font-size: 12px; }
#doc { display: grid; gap: 24px; margin-top: 1rem; }
.form-table h2 { margin: 0 0 8px; font-size: 16px; }
.table-wrap { overflow-x: auto; border: 1px solid #d7d7cf; background: #fff; }
table { width: 100%; border-collapse: collapse; min-width: 920px; }
th, td { border-bottom: 1px solid #e6e6df; padding: 8px 10px; text-align: left; vertical-align: top; }
th { position: sticky; top: 0; background: #efefea; color: #555; font-size: 12px; }
tbody tr:hover { background: #fafaf7; }
td { overflow-wrap: anywhere; white-space: pre-wrap; }
code { font-weight: 700; }
</style>
</head>
<body>
<div id="status">connecting…</div>
<main id="doc"><!-- FORMS_HTML --></main>
<script>
const status = document.getElementById("status");
const doc = document.getElementById("doc");
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
let ws = null;
let heartbeat = null;
let reconnectTimer = null;
let reconnectDelay = 500;
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
function stopHeartbeat() {
if (heartbeat) clearInterval(heartbeat);
heartbeat = null;
}
function scheduleReconnect() {
if (reconnectTimer) return;
status.textContent = "disconnected; reconnecting…";
stopHeartbeat();
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
}
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
status.textContent = "connecting…";
ws = new WebSocket(url);
ws.addEventListener("open", () => {
reconnectDelay = 500;
status.textContent = "connected";
send({type: "get_doc"});
stopHeartbeat();
heartbeat = setInterval(() => send({type: "ping"}), 25000);
});
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "doc") {
doc.innerHTML = msg.html;
status.textContent = "rendered backend document at " + new Date().toLocaleTimeString();
}
});
ws.addEventListener("close", scheduleReconnect);
ws.addEventListener("error", () => {
status.textContent = "connection error; reconnecting…";
ws.close();
});
}
window.addEventListener("beforeunload", stopHeartbeat);
connect();
</script>
</body>
</html>

11
priv/ui/graphyellow.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="30" height="28" viewBox="0 0 30 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="14.5" cy="14.5" r="6" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="26.5" cy="3.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="8.5" cy="3.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="3.5" cy="19.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<circle cx="26.5" cy="24.5" r="3" fill="#E1FF00" stroke="#FEBA7D"/>
<rect x="19.3531" y="10.04" width="6.20598" height="0.5" transform="rotate(-42 19.3531 10.04)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
<rect x="11.5018" y="8.76443" width="2.48193" height="0.5" transform="rotate(-118 11.5018 8.76443)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
<rect x="8.74289" y="17.7719" width="2.12617" height="0.5" transform="rotate(149 8.74289 17.7719)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
<rect x="19.692" y="18.3533" width="5.65271" height="0.5" transform="rotate(43 19.692 18.3533)" fill="#E1FF00" stroke="#FEBA7D" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,18 @@
# Pug Features Demo
This folder is a small Pug demo showing:
- interpolation with `#{name}`
- conditionals with `if` and `case`
- loops with `each`
- includes with `_summary.pug` and `_mixins.pug`
- layout inheritance with `extends ./layout.pug`
- reusable mixins with `+featureCard(feature)` and `+badge(...)`
Compile it with:
```bash
npx pug-cli priv/ui/pug-demo/index.pug --pretty --out priv/ui/pug-demo
```
That writes `priv/ui/pug-demo/index.html`.

View File

@@ -0,0 +1,8 @@
mixin badge(label, tone)
span.badge(class=`badge--${tone}`)= label
mixin featureCard(feature)
li.feature-card
h3 #{feature.name}
p.muted= feature.description
+badge(feature.statusLabel, feature.status)

View File

@@ -0,0 +1,12 @@
section.panel
h2 Included Summary
p
| This section comes from
code _summary.pug
| . It can still read variables declared in
code index.pug
| , including
strong #{name}
| and the feature count:
strong #{features.length}
| .

149
priv/ui/pug-demo/index.html Normal file
View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pug Features Demo</title>
<style>
:root {
color-scheme: light;
--ink: #202124;
--muted: #5f6368;
--line: #dadce0;
--paper: #ffffff;
--soft: #f7f8fa;
--accent: #0b57d0;
--accent-soft: #e8f0fe;
--good: #137333;
--warn: #b06000;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--soft);
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(960px, calc(100% - 32px));
margin: 32px auto;
}
header {
margin-bottom: 24px;
}
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: 32px; line-height: 1.1; }
h2 { font-size: 20px; margin-bottom: 12px; }
h3 { font-size: 16px; margin-bottom: 6px; }
.muted { color: var(--muted); }
.panel, .feature-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel {
padding: 18px;
margin-bottom: 16px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.feature-card {
padding: 14px;
}
.feature-card p { margin-bottom: 10px; }
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 650;
}
.badge--ready {
background: #e6f4ea;
color: var(--good);
}
.badge--practice {
background: #fef7e0;
color: var(--warn);
}
code {
background: #eef0f3;
border-radius: 4px;
padding: 1px 5px;
}
</style>
</head>
<body>
<main>
<header>
<h1>Pug features for Ada Lovelace</h1>
<p class="muted">This page is rendered from <code>index.pug</code> and extends <code>layout.pug</code>.</p>
</header>
<section class="panel">
<h2>Interpolation</h2>
<p>Hello, Ada Lovelace. Your current plan is PRO.</p>
<p>The literal syntax is <code>#{name}</code>, which inserts escaped text into a line.</p>
</section>
<section class="panel">
<h2>Conditionals</h2>
<p>Welcome back, Ada Lovelace.</p>
<p>You are using the pro plan, so all examples are visible.</p>
</section>
<section class="panel">
<h2>Loops and Mixins</h2>
<ul class="feature-grid">
<li class="feature-card">
<h3>Interpolation</h3>
<p class="muted">Drop values into text with #{name} and escaped output.</p><span class="badge badge--ready">ready</span>
</li>
<li class="feature-card">
<h3>Conditionals</h3>
<p class="muted">Render different branches with if, else if, else, and case.</p><span class="badge badge--ready">ready</span>
</li>
<li class="feature-card">
<h3>Loops</h3>
<p class="muted">Repeat markup with each item in collection syntax.</p><span class="badge badge--ready">ready</span>
</li>
<li class="feature-card">
<h3>Includes and extends</h3>
<p class="muted">Compose pages from layouts and smaller partial files.</p><span class="badge badge--practice">practice</span>
</li>
<li class="feature-card">
<h3>Mixins</h3>
<p class="muted">Create reusable snippets that accept arguments.</p><span class="badge badge--ready">ready</span>
</li>
</ul>
</section>
<section class="panel">
<h2>Included Summary</h2>
<p>This section comes from <code>_summary.pug</code>. It can still read variables declared in <code>index.pug</code>, including <strong>Ada Lovelace</strong> and the feature count: <strong>5</strong>.</p>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,80 @@
extends ./layout.pug
include ./_mixins.pug
block content
-
const name = "Ada Lovelace";
const signedIn = true;
const plan = "pro";
const features = [
{
name: "Interpolation",
description: "Drop values into text with #{name} and escaped output.",
status: "ready",
statusLabel: "ready"
},
{
name: "Conditionals",
description: "Render different branches with if, else if, else, and case.",
status: "ready",
statusLabel: "ready"
},
{
name: "Loops",
description: "Repeat markup with each item in collection syntax.",
status: "ready",
statusLabel: "ready"
},
{
name: "Includes and extends",
description: "Compose pages from layouts and smaller partial files.",
status: "practice",
statusLabel: "practice"
},
{
name: "Mixins",
description: "Create reusable snippets that accept arguments.",
status: "ready",
statusLabel: "ready"
}
];
header
h1 Pug features for #{name}
p.muted
| This page is rendered from
code index.pug
| and extends
code layout.pug
| .
section.panel
h2 Interpolation
p Hello, #{name}. Your current plan is #{plan.toUpperCase()}.
p
| The literal syntax is
code #{'#{name}'}
| , which inserts escaped text into a line.
section.panel
h2 Conditionals
if signedIn
p Welcome back, #{name}.
else
p Please sign in to see the demo.
case plan
when "free"
p You are using the free plan.
when "pro"
p You are using the pro plan, so all examples are visible.
default
p Your plan is #{plan}.
section.panel
h2 Loops and Mixins
ul.feature-grid
each feature in features
+featureCard(feature)
include ./_summary.pug

102
priv/ui/pug-demo/layout.pug Normal file
View File

@@ -0,0 +1,102 @@
doctype html
html(lang="en")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1")
title Pug Features Demo
style.
:root {
color-scheme: light;
--ink: #202124;
--muted: #5f6368;
--line: #dadce0;
--paper: #ffffff;
--soft: #f7f8fa;
--accent: #0b57d0;
--accent-soft: #e8f0fe;
--good: #137333;
--warn: #b06000;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--soft);
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(960px, calc(100% - 32px));
margin: 32px auto;
}
header {
margin-bottom: 24px;
}
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: 32px; line-height: 1.1; }
h2 { font-size: 20px; margin-bottom: 12px; }
h3 { font-size: 16px; margin-bottom: 6px; }
.muted { color: var(--muted); }
.panel, .feature-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel {
padding: 18px;
margin-bottom: 16px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.feature-card {
padding: 14px;
}
.feature-card p { margin-bottom: 10px; }
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 650;
}
.badge--ready {
background: #e6f4ea;
color: var(--good);
}
.badge--practice {
background: #fef7e0;
color: var(--warn);
}
code {
background: #eef0f3;
border-radius: 4px;
padding: 1px 5px;
}
body
main
block content

360
prod.log Normal file
View File

@@ -0,0 +1,360 @@
09:57:48.975 [info] Running Forum.Router with Bandit 1.11.1 at 0.0.0.0:4000 (http)
09:57:48.986 [info] Running Forum.PublicSiteRouter with Bandit 1.11.1 at 0.0.0.0:4001 (http)
09:57:48.995 [info] Running Forum.PublicSiteRouter with Bandit 1.11.1 at 0.0.0.0:4002 (http)
09:57:56.404 [info] GET /admin/logs
09:57:56.412 [info] Sent 401 in 7ms
09:57:56.509 [info] GET /favicon.ico
09:57:56.509 [info] Sent 401 in 57µs
09:57:58.239 [info] GET /admin/logs
09:57:58.239 [info] Sent 401 in 41µs
09:57:58.318 [info] GET /favicon.ico
09:57:58.318 [info] Sent 401 in 46µs
09:57:59.325 [info] GET /admin/logs
09:57:59.325 [info] Sent 401 in 32µs
09:57:59.411 [info] GET /favicon.ico
09:57:59.411 [info] Sent 401 in 36µs
09:58:04.955 [info] GET /
09:58:05.079 [info] ws connected: user_id=4
09:58:07.641 [info] GET /admin/logs
09:58:07.644 [info] Sent 200 in 2ms
09:58:07.817 [info] GET /admin/graphyellow.svg
09:58:07.819 [info] Sent 200 in 1ms
09:58:10.564 [info] GET /admin/logs
09:58:10.567 [info] Sent 200 in 3ms
09:58:10.721 [info] GET /admin/graphyellow.svg
09:58:10.722 [info] Sent 200 in 497µs
09:58:13.732 [info] Forum.Forms: reconciled, target=315 runtimes
09:58:22.343 [info] GET /
09:58:22.344 [info] ws connected: user_id=1
09:58:35.023 [info] GET /admin/processes
09:58:35.024 [info] Sent 200 in 603µs
09:58:35.305 [info] GET /admin/graphyellow.svg
09:58:35.306 [info] Sent 200 in 419µs
09:58:35.418 [info] GET /admin/ws
09:58:35.419 [info] ws connected: user_id=
09:59:04.960 [info] ws disconnected: {:error, :closed}
09:59:10.133 [info] GET /
09:59:10.134 [info] ws connected: user_id=4
09:59:16.426 [info] ws disconnected: :remote
09:59:22.344 [info] ws disconnected: {:error, :closed}
09:59:28.341 [info] GET /
09:59:28.342 [info] ws connected: user_id=1
10:00:28.343 [info] ws disconnected: {:error, :closed}
10:00:34.355 [info] GET /
10:00:34.356 [info] ws connected: user_id=1
10:01:34.359 [info] ws disconnected: {:error, :closed}
10:01:40.341 [info] GET /
10:01:40.342 [info] ws connected: user_id=1
10:02:40.342 [info] ws disconnected: {:error, :closed}
10:02:46.344 [info] GET /
10:02:46.346 [info] ws connected: user_id=1
10:03:46.345 [info] ws disconnected: {:error, :closed}
10:03:52.342 [info] GET /
10:03:52.343 [info] ws connected: user_id=1
10:04:01.127 [info] GET /robots.txt
10:04:01.128 [info] Sent 404 in 64µs
10:04:01.207 [info] GET /
10:04:01.207 [info] Sent 404 in 73µs
10:04:52.343 [info] ws disconnected: {:error, :closed}
10:04:58.341 [info] GET /
10:04:58.342 [info] ws connected: user_id=1
10:05:58.342 [info] ws disconnected: {:error, :closed}
10:06:04.333 [info] GET /
10:06:04.334 [info] ws connected: user_id=1
10:07:04.341 [info] ws disconnected: {:error, :closed}
10:07:10.342 [info] GET /
10:07:10.342 [info] ws connected: user_id=1
10:08:10.343 [info] ws disconnected: {:error, :closed}
10:08:16.336 [info] GET /
10:08:16.337 [info] ws connected: user_id=1
10:08:41.142 [info] ws disconnected: {:error, :closed}
10:09:16.337 [info] ws disconnected: {:error, :closed}
10:09:22.340 [info] GET /
10:09:22.341 [info] ws connected: user_id=1
10:10:22.341 [info] ws disconnected: {:error, :closed}
10:10:28.348 [info] GET /
10:10:28.349 [info] ws connected: user_id=1
10:11:28.349 [info] ws disconnected: {:error, :closed}
10:11:34.352 [info] GET /
10:11:34.353 [info] ws connected: user_id=1
10:12:34.357 [info] ws disconnected: {:error, :closed}
10:12:40.360 [info] GET /
10:12:40.360 [info] ws connected: user_id=1
10:13:40.361 [info] ws disconnected: {:error, :closed}
10:13:46.358 [info] GET /
10:13:46.359 [info] ws connected: user_id=1
10:14:46.360 [info] ws disconnected: {:error, :closed}
10:14:52.360 [info] GET /
10:14:52.361 [info] ws connected: user_id=1
10:15:52.361 [info] ws disconnected: {:error, :closed}
10:15:58.364 [info] GET /
10:15:58.365 [info] ws connected: user_id=1
10:16:58.364 [info] ws disconnected: {:error, :closed}
10:17:04.440 [info] GET /
10:17:04.441 [info] ws connected: user_id=1
10:18:04.443 [info] ws disconnected: {:error, :closed}
10:18:10.363 [info] GET /
10:18:10.364 [info] ws connected: user_id=1
10:19:10.365 [info] ws disconnected: {:error, :closed}
10:19:17.620 [info] GET /
10:19:17.621 [info] ws connected: user_id=1
10:20:17.621 [info] ws disconnected: {:error, :closed}
10:20:23.371 [info] GET /
10:20:23.372 [info] ws connected: user_id=1
10:21:23.378 [info] ws disconnected: {:error, :closed}
10:21:29.368 [info] GET /
10:21:29.369 [info] ws connected: user_id=1
10:22:29.381 [info] ws disconnected: {:error, :closed}
10:22:35.387 [info] GET /
10:22:35.388 [info] ws connected: user_id=1
10:23:35.405 [info] ws disconnected: {:error, :closed}
10:23:41.452 [info] GET /
10:23:41.453 [info] ws connected: user_id=1
10:24:41.472 [info] ws disconnected: {:error, :closed}
10:24:47.382 [info] GET /
10:24:47.383 [info] ws connected: user_id=1
10:25:47.382 [info] ws disconnected: {:error, :closed}
10:25:53.382 [info] GET /
10:25:53.383 [info] ws connected: user_id=1
10:26:53.389 [info] ws disconnected: {:error, :closed}
10:26:59.380 [info] GET /
10:26:59.380 [info] ws connected: user_id=1
10:27:59.392 [info] ws disconnected: {:error, :closed}
10:28:05.380 [info] GET /
10:28:05.381 [info] ws connected: user_id=1
10:28:06.138 [info] GET /robots.txt
10:28:06.138 [info] Sent 404 in 79µs
10:29:05.398 [info] ws disconnected: {:error, :closed}
10:29:11.395 [info] GET /
10:29:11.395 [info] ws connected: user_id=1
10:30:11.416 [info] ws disconnected: {:error, :closed}
10:30:17.386 [info] GET /
10:30:17.387 [info] ws connected: user_id=1
10:31:17.386 [info] ws disconnected: {:error, :closed}
10:31:23.381 [info] GET /
10:31:23.381 [info] ws connected: user_id=1
10:32:23.388 [info] ws disconnected: {:error, :closed}
10:32:29.378 [info] GET /
10:32:29.379 [info] ws connected: user_id=1
10:33:29.391 [info] ws disconnected: {:error, :closed}
10:33:35.378 [info] GET /
10:33:35.379 [info] ws connected: user_id=1
10:34:35.395 [info] ws disconnected: {:error, :closed}
10:34:41.386 [info] GET /
10:34:41.386 [info] ws connected: user_id=1
10:35:41.387 [info] ws disconnected: {:error, :closed}
10:35:47.382 [info] GET /
10:35:47.382 [info] ws connected: user_id=1
10:36:47.442 [info] ws disconnected: {:error, :closed}
10:36:53.382 [info] GET /
10:36:53.383 [info] ws connected: user_id=1
10:37:53.420 [info] ws disconnected: {:error, :closed}
10:37:59.387 [info] GET /
10:37:59.387 [info] ws connected: user_id=1
10:38:59.432 [info] ws disconnected: {:error, :closed}
10:39:05.387 [info] GET /
10:39:05.387 [info] ws connected: user_id=1
10:40:05.420 [info] ws disconnected: {:error, :closed}
10:40:11.387 [info] GET /
10:40:11.387 [info] ws connected: user_id=1
10:41:11.389 [info] ws disconnected: {:error, :closed}
10:41:17.389 [info] GET /
10:41:17.389 [info] ws connected: user_id=1
10:42:17.390 [info] ws disconnected: {:error, :closed}
10:42:23.383 [info] GET /
10:42:23.384 [info] ws connected: user_id=1
10:43:23.383 [info] ws disconnected: {:error, :closed}
10:43:29.391 [info] GET /
10:43:29.392 [info] ws connected: user_id=1
10:44:29.393 [info] ws disconnected: {:error, :closed}
10:44:35.384 [info] GET /
10:44:35.384 [info] ws connected: user_id=1
10:45:26.645 [info] GET /robots.txt
10:45:26.645 [info] Sent 404 in 111µs
10:45:35.392 [info] ws disconnected: {:error, :closed}
10:45:41.389 [info] GET /
10:45:41.389 [info] ws connected: user_id=1
10:46:41.389 [info] ws disconnected: {:error, :closed}
10:46:47.382 [info] GET /
10:46:47.383 [info] ws connected: user_id=1
10:47:47.388 [info] ws disconnected: {:error, :closed}
10:47:53.381 [info] GET /
10:47:53.382 [info] ws connected: user_id=1
10:48:53.387 [info] ws disconnected: {:error, :closed}
10:48:59.378 [info] GET /
10:48:59.378 [info] ws connected: user_id=1
10:49:59.379 [info] ws disconnected: {:error, :closed}
10:50:05.388 [info] GET /
10:50:05.389 [info] ws connected: user_id=1
10:51:05.393 [info] ws disconnected: {:error, :closed}
10:51:11.385 [info] GET /
10:51:11.385 [info] ws connected: user_id=1
10:52:11.390 [info] ws disconnected: {:error, :closed}
10:52:17.380 [info] GET /
10:52:17.380 [info] ws connected: user_id=1
10:53:17.381 [info] ws disconnected: {:error, :closed}
10:53:23.384 [info] GET /
10:53:23.384 [info] ws connected: user_id=1
10:54:23.385 [info] ws disconnected: {:error, :closed}
10:54:29.378 [info] GET /
10:54:29.378 [info] ws connected: user_id=1
10:55:29.380 [info] ws disconnected: {:error, :closed}
10:55:35.382 [info] GET /
10:55:35.383 [info] ws connected: user_id=1
10:56:35.382 [info] ws disconnected: {:error, :closed}
10:56:41.380 [info] GET /
10:56:41.381 [info] ws connected: user_id=1
10:56:46.163 [info] GET /
10:56:46.163 [info] Sent 404 in 37µs
10:57:41.381 [info] ws disconnected: {:error, :closed}
10:57:47.373 [info] GET /
10:57:47.374 [info] ws connected: user_id=1
10:58:47.375 [info] ws disconnected: {:error, :closed}
10:58:53.376 [info] GET /
10:58:53.377 [info] ws connected: user_id=1
10:59:53.376 [info] ws disconnected: {:error, :closed}
10:59:59.382 [info] GET /
10:59:59.382 [info] ws connected: user_id=1
11:00:59.384 [info] ws disconnected: {:error, :closed}
11:01:05.397 [info] GET /
11:01:05.398 [info] ws connected: user_id=1
11:02:05.397 [info] ws disconnected: {:error, :closed}
11:02:11.378 [info] GET /
11:02:11.378 [info] ws connected: user_id=1
11:03:11.382 [info] ws disconnected: {:error, :closed}
11:03:17.381 [info] GET /
11:03:17.382 [info] ws connected: user_id=1
11:04:17.381 [info] ws disconnected: {:error, :closed}
11:04:23.378 [info] GET /
11:04:23.378 [info] ws connected: user_id=1
11:05:23.379 [info] ws disconnected: {:error, :closed}
11:05:29.377 [info] GET /
11:05:29.378 [info] ws connected: user_id=1
11:06:29.380 [info] ws disconnected: {:error, :closed}
11:06:35.380 [info] GET /
11:06:35.380 [info] ws connected: user_id=1
11:07:35.385 [info] ws disconnected: {:error, :closed}
11:07:41.380 [info] GET /
11:07:41.381 [info] ws connected: user_id=1
11:08:41.387 [info] ws disconnected: {:error, :closed}
11:08:47.378 [info] GET /
11:08:47.378 [info] ws connected: user_id=1
11:09:47.407 [info] ws disconnected: {:error, :closed}
11:09:53.377 [info] GET /
11:09:53.377 [info] ws connected: user_id=1
11:10:53.382 [info] ws disconnected: {:error, :closed}
11:10:59.394 [info] GET /
11:10:59.395 [info] ws connected: user_id=1
11:11:59.405 [info] ws disconnected: {:error, :closed}
11:12:05.378 [info] GET /
11:12:05.378 [info] ws connected: user_id=1
11:13:05.395 [info] ws disconnected: {:error, :closed}
11:13:11.375 [info] GET /
11:13:11.376 [info] ws connected: user_id=1
11:14:11.385 [info] ws disconnected: {:error, :closed}
11:14:17.378 [info] GET /
11:14:17.379 [info] ws connected: user_id=1
11:15:17.435 [info] ws disconnected: {:error, :closed}
11:15:23.383 [info] GET /
11:15:23.383 [info] ws connected: user_id=1
11:16:23.442 [info] ws disconnected: {:error, :closed}
11:16:29.386 [info] GET /
11:16:29.387 [info] ws connected: user_id=1
11:17:29.436 [info] ws disconnected: {:error, :closed}
11:17:35.379 [info] GET /
11:17:35.380 [info] ws connected: user_id=1
11:18:35.439 [info] ws disconnected: {:error, :closed}
11:18:41.376 [info] GET /
11:18:41.376 [info] ws connected: user_id=1
11:19:41.410 [info] ws disconnected: {:error, :closed}
11:19:47.373 [info] GET /
11:19:47.374 [info] ws connected: user_id=1
11:20:47.380 [info] ws disconnected: {:error, :closed}
11:20:53.364 [info] GET /
11:20:53.365 [info] ws connected: user_id=1
11:21:53.366 [info] ws disconnected: {:error, :closed}
11:21:59.358 [info] GET /
11:21:59.359 [info] ws connected: user_id=1
11:22:59.359 [info] ws disconnected: {:error, :closed}
11:23:05.365 [info] GET /
11:23:05.366 [info] ws connected: user_id=1
11:24:05.366 [info] ws disconnected: {:error, :closed}
11:24:11.359 [info] GET /
11:24:11.360 [info] ws connected: user_id=1
11:25:11.380 [info] ws disconnected: {:error, :closed}
11:25:17.356 [info] GET /
11:25:17.357 [info] ws connected: user_id=1
11:26:17.380 [info] ws disconnected: {:error, :closed}
11:26:23.353 [info] GET /
11:26:23.354 [info] ws connected: user_id=1
11:27:23.357 [info] ws disconnected: {:error, :closed}
11:27:29.346 [info] GET /
11:27:29.347 [info] ws connected: user_id=1
11:28:29.356 [info] ws disconnected: {:error, :closed}
11:28:35.349 [info] GET /
11:28:35.350 [info] ws connected: user_id=1
11:29:35.366 [info] ws disconnected: {:error, :closed}
11:29:41.357 [info] GET /
11:29:41.358 [info] ws connected: user_id=1
11:30:41.380 [info] ws disconnected: {:error, :closed}
11:30:47.343 [info] GET /
11:30:47.344 [info] ws connected: user_id=1
11:31:06.936 [info] GET /
11:31:06.936 [info] Sent 404 in 64µs
11:31:47.354 [info] ws disconnected: {:error, :closed}
11:31:53.355 [info] GET /
11:31:53.356 [info] ws connected: user_id=1
11:32:53.359 [info] ws disconnected: {:error, :closed}
11:32:59.340 [info] GET /
11:32:59.340 [info] ws connected: user_id=1
11:33:59.349 [info] ws disconnected: {:error, :closed}
11:34:05.342 [info] GET /
11:34:05.343 [info] ws connected: user_id=1
11:35:05.348 [info] ws disconnected: {:error, :closed}
11:35:11.329 [info] GET /
11:35:11.330 [info] ws connected: user_id=1
11:36:11.330 [info] ws disconnected: {:error, :closed}
11:36:17.343 [info] GET /
11:36:17.344 [info] ws connected: user_id=1
11:37:17.343 [info] ws disconnected: {:error, :closed}
11:37:23.335 [info] GET /
11:37:23.336 [info] ws connected: user_id=1
11:38:23.336 [info] ws disconnected: {:error, :closed}
11:38:29.336 [info] GET /
11:38:29.336 [info] ws connected: user_id=1
11:39:29.337 [info] ws disconnected: {:error, :closed}
11:39:35.334 [info] GET /
11:39:35.335 [info] ws connected: user_id=1
11:40:35.336 [info] ws disconnected: {:error, :closed}
11:40:41.333 [info] GET /
11:40:41.334 [info] ws connected: user_id=1
11:41:41.333 [info] ws disconnected: {:error, :closed}
11:41:47.324 [info] GET /
11:41:47.324 [info] ws connected: user_id=1
11:42:47.324 [info] ws disconnected: {:error, :closed}
11:42:53.332 [info] GET /
11:42:53.333 [info] ws connected: user_id=1
11:43:53.332 [info] ws disconnected: {:error, :closed}
11:43:59.330 [info] GET /
11:43:59.330 [info] ws connected: user_id=1
11:44:59.332 [info] ws disconnected: {:error, :closed}
11:45:05.329 [info] GET /
11:45:05.330 [info] ws connected: user_id=1
11:46:05.330 [info] ws disconnected: {:error, :closed}
11:46:11.329 [info] GET /
11:46:11.330 [info] ws connected: user_id=1
11:47:11.330 [info] ws disconnected: {:error, :closed}
11:47:17.360 [info] GET /
11:47:17.360 [info] ws connected: user_id=1
11:48:17.385 [info] ws disconnected: {:error, :closed}
11:48:23.328 [info] GET /
11:48:23.329 [info] ws connected: user_id=1
11:49:23.332 [info] ws disconnected: {:error, :closed}
11:49:29.324 [info] GET /
11:49:29.325 [info] ws connected: user_id=1
11:50:29.334 [info] ws disconnected: {:error, :closed}
11:50:35.347 [info] GET /
11:50:35.348 [info] ws connected: user_id=1
11:51:35.363 [info] ws disconnected: {:error, :closed}
11:51:41.328 [info] GET /
11:51:41.329 [info] ws connected: user_id=1

8
restart.sh Executable file
View File

@@ -0,0 +1,8 @@
set -e
lsof -ti:4000 | xargs kill
elixir --name a@10.0.0.1 --cookie supersecret --erl "-detached -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100" -S mix run --no-halt
sleep 2
iex --name console@localhost --cookie supersecret --remsh a@10.0.0.1

1
start.sh Executable file
View File

@@ -0,0 +1 @@
MIX_ENV=prod elixir --name a@10.0.0.1 --cookie lookatthepreviousweekend --erl "-detached -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100" -S mix run --no-halt

1
stop.sh Executable file
View File

@@ -0,0 +1 @@
lsof -ti:4000 | xargs kill

1
terminal.sh Executable file
View File

@@ -0,0 +1 @@
iex --name console@localhost --cookie lookatthepreviousweekend --remsh a@10.0.0.1