work in progress

This commit is contained in:
Sam Hoffman
2026-01-09 13:05:21 -05:00
commit 238b9a31a1
27 changed files with 4883 additions and 0 deletions

22
docker-compose.yaml Normal file
View File

@@ -0,0 +1,22 @@
version: "3.8"
services:
ergo:
init: true
image: ghcr.io/ergochat/ergo:master
ports:
- "6667:6667/tcp"
- "8097:8097"
volumes:
- data:/ircd
- ./ircd.yaml:/ircd/ircd.yaml
deploy:
placement:
constraints:
- "node.role == manager"
restart_policy:
condition: on-failure
replicas: 1
volumes:
data:

1040
ircd.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
irchad-web/.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -0,0 +1,82 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"Slot": true,
"Slots": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"defineStore": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"storeToRefs": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useId": true,
"useModel": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"useTemplateRef": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

22
irchad-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,3 @@
import vuetify from 'eslint-config-vuetify'
export default vuetify()

13
irchad-web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Vuetify 3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

20
irchad-web/jsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "bundler",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

34
irchad-web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "irchad-web",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix"
},
"dependencies": {
"@fontsource/roboto": "5.2.7",
"@mdi/font": "7.4.47",
"irc-framework": "^4.14.0",
"pinia": "^3.0.3",
"vue": "^3.5.21",
"vue-router": "^4.5.1",
"vuetify": "^3.10.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.35.0",
"eslint-config-vuetify": "^4.2.0",
"sass-embedded": "^1.92.1",
"unplugin-auto-import": "^19.3.0",
"unplugin-fonts": "^1.4.0",
"unplugin-vue-components": "^29.0.0",
"unplugin-vue-router": "^0.15.0",
"vite": "^7.1.5",
"vite-plugin-vue-layouts-next": "^1.0.0",
"vite-plugin-vuetify": "^2.1.2"
}
}

9
irchad-web/src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<v-app>
<router-view />
</v-app>
</template>
<script setup>
//
</script>

View File

@@ -0,0 +1,21 @@
<script setup>
import { computed } from "vue";
import { useIRCStore } from "@/stores/irc";
const { buffers } = useIRCStore();
const store = useIRCStore();
const bufferList = computed(() => {
return Object.keys(buffers);
});
function click(bufferName) {
store.setActiveBuffer(bufferName);
}
</script>
<template>
<v-list>
<v-list-item v-for="buf in bufferList" @click="click(buf)">
{{ buf }}
</v-list-item>
</v-list>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
import { ref, onMounted } from "vue";
import { useIRCStore } from "@/stores/irc";
const store = useIRCStore();
const inputBuffer = ref();
onMounted(() => {
store.connect();
});
function send() {
store.sendActiveBuffer(inputBuffer.value);
inputBuffer.value = "";
}
</script>
<template>
<div class="d-flex flex-row" style="height: 100vh">
<v-sheet border class="buffers">
<BufferList />
</v-sheet>
<div class="messages d-flex flex-column">
<MessageList
:messages="store.activeBuffer?.messages"
:me="store.clientInfo.nick"
/>
<v-sheet>
<v-text-field
variant="outlined"
:placeholder="`Message ${store.activeBufferName}`"
v-model="inputBuffer"
hide-details
class="ma-2"
@keydown.enter.exact.prevent="send"
/>
</v-sheet>
</div>
<v-sheet class="user-list h-100" border>
<UserList :users="store.activeBuffer?.users" />
</v-sheet>
</div>
</template>
<style>
.buffers {
height: 100%;
flex: 1;
}
.messages {
height: 100%;
flex: 2;
justify-content: space-between;
}
.user-list {
height: 100%;
flex: 1;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup>
import { computed } from "vue";
import { useIRCStore } from "@/stores/irc";
const props = defineProps(["messages", "me"]);
const store = useIRCStore();
const messagesReverse = computed(() => {
if (props.messages) {
return [...props.messages];
}
});
const timeFormatter = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
function formatTime(ts) {
const date = new Date(ts);
return timeFormatter.format(date);
}
</script>
<template>
<v-sheet class="message-list d-flex">
<v-list>
<v-list-item
v-for="msg in messagesReverse"
density="compact"
:prepend-avatar="store.getMetadata(msg.nick, 'avatar')"
>
<v-list-item-title>
<span
class="message-nick"
:class="{ 'text-primary': me === msg.nick }"
>
{{ msg.nick }}
</span>
<span class="message-time" v-if="!!msg.time">{{
formatTime(msg.time)
}}</span>
</v-list-item-title>
<v-list-item-subtitle>
{{ msg.message }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-sheet>
</template>
<style scoped>
.message-list {
height: 100%;
overflow-y: auto;
flex-direction: column-reverse;
}
.message-time {
font-size: 0.65em;
margin-left: 4px;
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup>
import { useIRCStore } from "@/stores/irc";
const props = defineProps(["users"]);
const store = useIRCStore();
</script>
<template>
<v-list density="compact">
<v-list-item
v-for="user in users"
:prepend-avatar="store.getMetadata(user.nick, 'avatar')"
>
{{ user.nick }}
</v-list-item>
</v-list>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<v-main>
<router-view />
</v-main>
</template>
<script setup>
//
</script>

23
irchad-web/src/main.js Normal file
View File

@@ -0,0 +1,23 @@
/**
* main.js
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Composables
import { createApp } from 'vue'
// Styles
import 'unfonts.css'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

View File

@@ -0,0 +1,7 @@
<template>
<Chat />
</template>
<script setup>
//
</script>

View File

@@ -0,0 +1,17 @@
/**
* plugins/index.js
*
* Automatically included in `./src/main.js`
*/
// Plugins
import vuetify from './vuetify'
import pinia from '@/stores'
import router from '@/router'
export function registerPlugins (app) {
app
.use(vuetify)
.use(router)
.use(pinia)
}

View File

@@ -0,0 +1,19 @@
/**
* plugins/vuetify.js
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'system',
},
})

View File

@@ -0,0 +1,36 @@
/**
* router/index.ts
*
* Automatic routes for `./src/pages/*.vue`
*/
// Composables
import { createRouter, createWebHistory } from 'vue-router'
import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: setupLayouts(routes),
})
// Workaround for https://github.com/vitejs/vite/issues/11804
router.onError((err, to) => {
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {
if (localStorage.getItem('vuetify:dynamic-reload')) {
console.error('Dynamic import error, reloading page did not fix it', err)
} else {
console.log('Reloading page to fix dynamic import error')
localStorage.setItem('vuetify:dynamic-reload', 'true')
location.assign(to.fullPath)
}
} else {
console.error(err)
}
})
router.isReady().then(() => {
localStorage.removeItem('vuetify:dynamic-reload')
})
export default router

View File

@@ -0,0 +1,8 @@
// Utilities
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
//
}),
})

View File

@@ -0,0 +1,4 @@
// Utilities
import { createPinia } from 'pinia'
export default createPinia()

View File

@@ -0,0 +1,178 @@
import { defineStore } from "pinia";
import { Client } from "irc-framework";
function autoNick() {
const discriminiator = Math.round(Math.random() * 100);
return `chad-${discriminiator}`;
}
export const useIRCStore = defineStore("irc", () => {
const clientInfo = ref({
nick: autoNick(),
username: "chad",
gecos: "IrChad",
});
const buffers = ref({});
const activeBufferName = ref();
const metadata = ref({});
const activeBuffer = computed(() => {
if (activeBufferName.value) {
return buffers.value[activeBufferName.value];
}
});
function getMetadata(subject, key) {
if (metadata.value[subject]) return metadata.value[subject][key];
}
function setActiveBuffer(bufferName) {
activeBufferName.value = bufferName;
}
const client = new Client({
enable_echomessage: true,
});
function connect() {
client.requestCap("draft/metadata-2");
client.requestCap("echo-message");
const tls = location.protocol === "https:";
client.connect({
...clientInfo.value,
host: location.hostname,
tls,
port: location.port,
version: "irchad irc-framework",
path: "/ws",
});
}
function sendActiveBuffer(message) {
client.say(activeBufferName.value, message);
// const buffer = getBuffer(activeBufferName.value);
//
// buffer.messages.push({
// nick: clientInfo.value.nick,
// message,
// time: Date.now(),
// });
}
function isMe(target) {
return target === clientInfo.value.nick;
}
function addBuffer(bufferName) {
buffers.value[bufferName] = {
messages: [],
topic: "",
users: [],
};
return buffers.value[bufferName];
}
function getBuffer(bufferName) {
return buffers.value[bufferName];
}
function delBuffer(bufferName) {
if (buffers.value[bufferName]) {
delete buffers.value[bufferName];
}
}
client.on("registered", function () {
client.list();
client.raw("METADATA * SUB avatar");
client.raw("METADATA * SET avatar https://placekittens.com/128/128");
});
client.on("unknown command", function (ircCommand) {
if (ircCommand.command === "METADATA") {
const from = ircCommand.params[0];
const target = ircCommand.params[2];
const key = ircCommand.params[1];
const value = ircCommand.params[3];
let subject = target;
if (target === "*") {
subject = from;
}
if (!metadata.value[subject]) {
metadata.value[subject] = {};
}
metadata.value[subject][key] = value;
}
});
client.on("channel list", (channels) =>
channels.map((ch) => client.join(ch.channel)),
);
client.on("message", function (message) {
let buffer;
if (isMe(message.target)) {
buffer = getBuffer(message.nick);
} else {
buffer = getBuffer(message.target);
}
buffer.messages.push(message);
});
client.on("join", (ev) => {
const nick = ev.nick;
const channel = ev.channel;
console.log(ev);
if (isMe(nick)) {
addBuffer(channel);
if (!activeBufferName.value) {
activeBufferName.value = channel;
}
return;
}
const buffer = getBuffer(channel);
if (!buffer) return;
buffer.users.push({
nick: ev.nick,
gecos: ev.gecos,
hostname: ev.hostname,
ident: ev.ident,
});
});
client.on("part", ({ nick, channel }) => {
if (isMe(nick)) {
delBuffer(channel);
}
const buffer = getBuffer(channel);
if (!buffer) return;
const idx = buffer.users.find((u) => u.nick === nick);
if (idx === -1) return;
buffer.users.splice(idx, 1);
});
client.on("userlist", (ev) => {
const buffer = getBuffer(ev.channel);
if (!buffer) return;
buffer.users = ev.users;
});
return {
clientInfo,
connect,
client,
buffers,
activeBufferName,
activeBuffer,
sendActiveBuffer,
setActiveBuffer,
getMetadata,
metadata,
};
});

View File

@@ -0,0 +1,10 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );

View File

@@ -0,0 +1,80 @@
// Plugins
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import Fonts from "unplugin-fonts/vite";
import Layouts from "vite-plugin-vue-layouts-next";
import Vue from "@vitejs/plugin-vue";
import VueRouter from "unplugin-vue-router/vite";
import { VueRouterAutoImports } from "unplugin-vue-router";
import Vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
// Utilities
import { defineConfig } from "vite";
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter(),
Layouts(),
Vue({
template: { transformAssetUrls },
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({
autoImport: true,
styles: {
configFile: "src/styles/settings.scss",
},
}),
Components(),
Fonts({
google: {
families: [
{
name: "Roboto",
styles: "wght@100;300;400;500;700;900",
},
],
},
}),
AutoImport({
imports: [
"vue",
VueRouterAutoImports,
{
pinia: ["defineStore", "storeToRefs"],
},
],
eslintrc: {
enabled: true,
},
vueTemplate: true,
}),
],
optimizeDeps: {
exclude: [
"vuetify",
"vue-router",
"unplugin-vue-router/runtime",
"unplugin-vue-router/data-loaders",
"unplugin-vue-router/data-loaders/basic",
],
},
define: { "process.env": {} },
resolve: {
alias: {
"@": fileURLToPath(new URL("src", import.meta.url)),
},
extensions: [".js", ".json", ".jsx", ".mjs", ".ts", ".tsx", ".vue"],
},
server: {
port: 3000,
proxy: {
"/ws": {
target: "ws://localhost:8097",
ws: "true",
},
},
},
});

3081
irchad-web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff