Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
cbea265775
20 changed files with 730 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
BIN
apps/.DS_Store
vendored
Normal file
BIN
apps/.DS_Store
vendored
Normal file
Binary file not shown.
7
apps/contacts/crimata.json
Normal file
7
apps/contacts/crimata.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"id": "contacts",
|
||||
"name": "Contacts",
|
||||
"port": 3001,
|
||||
"db": true,
|
||||
"migrate": "npm run migrate"
|
||||
}
|
||||
22
apps/contacts/package.json
Normal file
22
apps/contacts/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "crimata-contacts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "node -r tsx/cjs src/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
5
apps/contacts/src/db.ts
Normal file
5
apps/contacts/src/db.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Pool } from "pg"
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
})
|
||||
9
apps/contacts/src/index.ts
Normal file
9
apps/contacts/src/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import express from "express"
|
||||
import { router } from "./routes"
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use("/contacts", router)
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001)
|
||||
app.listen(port, () => console.log(`contacts app listening on :${port}`))
|
||||
21
apps/contacts/src/migrate.ts
Normal file
21
apps/contacts/src/migrate.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { pool } from "./db"
|
||||
|
||||
async function migrate() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
avatar_url TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
console.log("contacts: migration complete")
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error("contacts: migration failed", err)
|
||||
process.exit(1)
|
||||
})
|
||||
61
apps/contacts/src/routes.ts
Normal file
61
apps/contacts/src/routes.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Router } from "express"
|
||||
import { pool } from "./db"
|
||||
import type { CreateContactBody, UpdateContactBody } from "./types"
|
||||
|
||||
export const router = Router()
|
||||
|
||||
// List all contacts
|
||||
router.get("/", async (req, res) => {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM contacts ORDER BY name ASC"
|
||||
)
|
||||
res.json(result.rows)
|
||||
})
|
||||
|
||||
// Get a single contact
|
||||
router.get("/:id", async (req, res) => {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM contacts WHERE id = $1",
|
||||
[req.params.id]
|
||||
)
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: "Not found" })
|
||||
res.json(result.rows[0])
|
||||
})
|
||||
|
||||
// Create a contact
|
||||
router.post("/", async (req, res) => {
|
||||
const { name, domain, avatar_url, notes } = req.body as CreateContactBody
|
||||
if (!name || !domain) {
|
||||
return res.status(400).json({ error: "name and domain are required" })
|
||||
}
|
||||
const result = await pool.query(
|
||||
`INSERT INTO contacts (name, domain, avatar_url, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[name, domain, avatar_url ?? null, notes ?? null]
|
||||
)
|
||||
res.status(201).json(result.rows[0])
|
||||
})
|
||||
|
||||
// Update a contact
|
||||
router.patch("/:id", async (req, res) => {
|
||||
const { name, domain, avatar_url, notes } = req.body as UpdateContactBody
|
||||
const result = await pool.query(
|
||||
`UPDATE contacts
|
||||
SET name = COALESCE($1, name),
|
||||
domain = COALESCE($2, domain),
|
||||
avatar_url = COALESCE($3, avatar_url),
|
||||
notes = COALESCE($4, notes)
|
||||
WHERE id = $5
|
||||
RETURNING *`,
|
||||
[name ?? null, domain ?? null, avatar_url ?? null, notes ?? null, req.params.id]
|
||||
)
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: "Not found" })
|
||||
res.json(result.rows[0])
|
||||
})
|
||||
|
||||
// Delete a contact
|
||||
router.delete("/:id", async (req, res) => {
|
||||
await pool.query("DELETE FROM contacts WHERE id = $1", [req.params.id])
|
||||
res.status(204).send()
|
||||
})
|
||||
22
apps/contacts/src/types.ts
Normal file
22
apps/contacts/src/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export interface Contact {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
avatar_url: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateContactBody {
|
||||
name: string
|
||||
domain: string
|
||||
avatar_url?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface UpdateContactBody {
|
||||
name?: string
|
||||
domain?: string
|
||||
avatar_url?: string
|
||||
notes?: string
|
||||
}
|
||||
13
apps/contacts/tsconfig.json
Normal file
13
apps/contacts/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
auth/Makefile
Normal file
13
auth/Makefile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2
|
||||
LIBS = -lpam -lmicrohttpd
|
||||
SRC = src/main.c src/pam_auth.c
|
||||
OUT = crimata-auth
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all:
|
||||
$(CC) $(CFLAGS) $(SRC) $(LIBS) -o $(OUT)
|
||||
|
||||
clean:
|
||||
rm -f $(OUT)
|
||||
149
auth/src/main.c
Normal file
149
auth/src/main.c
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <microhttpd.h>
|
||||
#include "pam_auth.h"
|
||||
|
||||
#define PORT 7700
|
||||
#define MAX_BODY 4096
|
||||
|
||||
/* ── JSON helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static const char *json_get(const char *json, const char *key, char *out, size_t out_len)
|
||||
{
|
||||
char search[128];
|
||||
snprintf(search, sizeof(search), "\"%s\"", key);
|
||||
const char *pos = strstr(json, search);
|
||||
if (!pos) return NULL;
|
||||
|
||||
pos += strlen(search);
|
||||
while (*pos == ' ' || *pos == ':' || *pos == ' ') pos++;
|
||||
if (*pos != '"') return NULL;
|
||||
pos++;
|
||||
|
||||
size_t i = 0;
|
||||
while (*pos && *pos != '"' && i < out_len - 1)
|
||||
out[i++] = *pos++;
|
||||
out[i] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
static enum MHD_Result send_json(struct MHD_Connection *conn,
|
||||
unsigned int status,
|
||||
const char *body)
|
||||
{
|
||||
struct MHD_Response *resp = MHD_create_response_from_buffer(
|
||||
strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
|
||||
MHD_add_response_header(resp, "Content-Type", "application/json");
|
||||
enum MHD_Result ret = MHD_queue_response(conn, status, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* ── Request context ──────────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char body[MAX_BODY];
|
||||
size_t body_len;
|
||||
} request_ctx_t;
|
||||
|
||||
/* ── Route handlers ───────────────────────────────────────────────────────── */
|
||||
|
||||
static enum MHD_Result handle_health(struct MHD_Connection *conn)
|
||||
{
|
||||
return send_json(conn, MHD_HTTP_OK, "{\"status\":\"ok\"}");
|
||||
}
|
||||
|
||||
static enum MHD_Result handle_auth(struct MHD_Connection *conn, const char *body)
|
||||
{
|
||||
char username[256] = {0};
|
||||
char password[256] = {0};
|
||||
|
||||
if (!json_get(body, "username", username, sizeof(username)) ||
|
||||
!json_get(body, "password", password, sizeof(password))) {
|
||||
return send_json(conn, MHD_HTTP_BAD_REQUEST,
|
||||
"{\"success\":false,\"error\":\"username and password required\"}");
|
||||
}
|
||||
|
||||
int result = authenticate(username, password);
|
||||
if (result == 0) {
|
||||
return send_json(conn, MHD_HTTP_OK, "{\"success\":true}");
|
||||
}
|
||||
return send_json(conn, MHD_HTTP_UNAUTHORIZED,
|
||||
"{\"success\":false,\"error\":\"invalid credentials\"}");
|
||||
}
|
||||
|
||||
/* ── Main handler ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static enum MHD_Result handler(void *cls,
|
||||
struct MHD_Connection *conn,
|
||||
const char *url,
|
||||
const char *method,
|
||||
const char *version,
|
||||
const char *upload_data,
|
||||
size_t *upload_data_size,
|
||||
void **con_cls)
|
||||
{
|
||||
(void)cls; (void)version;
|
||||
|
||||
/* Health check */
|
||||
if (strcmp(url, "/health") == 0 && strcmp(method, "GET") == 0)
|
||||
return handle_health(conn);
|
||||
|
||||
/* Auth — collect body first */
|
||||
if (strcmp(url, "/auth") == 0 && strcmp(method, "POST") == 0) {
|
||||
if (!*con_cls) {
|
||||
request_ctx_t *ctx = calloc(1, sizeof(request_ctx_t));
|
||||
if (!ctx) return MHD_NO;
|
||||
*con_cls = ctx;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
request_ctx_t *ctx = *con_cls;
|
||||
|
||||
if (*upload_data_size > 0) {
|
||||
size_t remaining = MAX_BODY - ctx->body_len - 1;
|
||||
size_t to_copy = *upload_data_size < remaining ? *upload_data_size : remaining;
|
||||
memcpy(ctx->body + ctx->body_len, upload_data, to_copy);
|
||||
ctx->body_len += to_copy;
|
||||
*upload_data_size = 0;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
return handle_auth(conn, ctx->body);
|
||||
}
|
||||
|
||||
return send_json(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"not found\"}");
|
||||
}
|
||||
|
||||
static void request_completed(void *cls, struct MHD_Connection *conn,
|
||||
void **con_cls, enum MHD_RequestTerminationCode toe)
|
||||
{
|
||||
(void)cls; (void)conn; (void)toe;
|
||||
if (*con_cls) { free(*con_cls); *con_cls = NULL; }
|
||||
}
|
||||
|
||||
/* ── Entry point ──────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void)
|
||||
{
|
||||
struct MHD_Daemon *daemon = MHD_start_daemon(
|
||||
MHD_USE_INTERNAL_POLLING_THREAD,
|
||||
PORT,
|
||||
NULL, NULL,
|
||||
&handler, NULL,
|
||||
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
|
||||
MHD_OPTION_END
|
||||
);
|
||||
|
||||
if (!daemon) {
|
||||
fprintf(stderr, "failed to start daemon on port %d\n", PORT);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("crimata-auth listening on :%d\n", PORT);
|
||||
getchar(); /* block until killed */
|
||||
|
||||
MHD_stop_daemon(daemon);
|
||||
return 0;
|
||||
}
|
||||
50
auth/src/pam_auth.c
Normal file
50
auth/src/pam_auth.c
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <security/pam_appl.h>
|
||||
#include "pam_auth.h"
|
||||
|
||||
typedef struct {
|
||||
const char *password;
|
||||
} pam_credentials_t;
|
||||
|
||||
static int pam_conversation(int num_msg, const struct pam_message **msg,
|
||||
struct pam_response **resp, void *appdata_ptr)
|
||||
{
|
||||
pam_credentials_t *creds = (pam_credentials_t *)appdata_ptr;
|
||||
|
||||
*resp = calloc(num_msg, sizeof(struct pam_response));
|
||||
if (!*resp) return PAM_BUF_ERR;
|
||||
|
||||
for (int i = 0; i < num_msg; i++) {
|
||||
if (msg[i]->msg_style == PAM_PROMPT_ECHO_OFF ||
|
||||
msg[i]->msg_style == PAM_PROMPT_ECHO_ON) {
|
||||
(*resp)[i].resp = strdup(creds->password);
|
||||
if (!(*resp)[i].resp) {
|
||||
free(*resp);
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PAM_SUCCESS;
|
||||
}
|
||||
|
||||
int authenticate(const char *username, const char *password)
|
||||
{
|
||||
pam_credentials_t creds = { .password = password };
|
||||
struct pam_conv conv = { pam_conversation, &creds };
|
||||
pam_handle_t *pamh = NULL;
|
||||
int result;
|
||||
|
||||
result = pam_start("login", username, &conv, &pamh);
|
||||
if (result != PAM_SUCCESS) goto done;
|
||||
|
||||
result = pam_authenticate(pamh, PAM_SILENT);
|
||||
if (result != PAM_SUCCESS) goto done;
|
||||
|
||||
result = pam_acct_mgmt(pamh, PAM_SILENT);
|
||||
|
||||
done:
|
||||
pam_end(pamh, result);
|
||||
return (result == PAM_SUCCESS) ? 0 : -1;
|
||||
}
|
||||
10
auth/src/pam_auth.h
Normal file
10
auth/src/pam_auth.h
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#ifndef PAM_AUTH_H
|
||||
#define PAM_AUTH_H
|
||||
|
||||
/*
|
||||
* authenticate - verify username/password against PAM.
|
||||
* Returns 0 on success, -1 on failure.
|
||||
*/
|
||||
int authenticate(const char *username, const char *password);
|
||||
|
||||
#endif
|
||||
66
ui/index.html
Normal file
66
ui/index.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Crimata</title>
|
||||
<link rel="stylesheet" href="styles/canvas.css" />
|
||||
<link rel="stylesheet" href="styles/window.css" />
|
||||
<link rel="stylesheet" href="styles/dock.css" />
|
||||
<link rel="stylesheet" href="styles/cursor.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body x-data="os" @keydown.window="handleKeydown">
|
||||
|
||||
<!-- Infinite canvas -->
|
||||
<div id="canvas"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
:style="`transform: translate(${offset.x}px, ${offset.y}px)`"
|
||||
>
|
||||
<!-- Windows render here -->
|
||||
<template x-for="win in windows" :key="win.id">
|
||||
<div class="window"
|
||||
:style="`left:${win.x}px; top:${win.y}px; width:${win.w}px; height:${win.h}px; z-index:${win.z}`"
|
||||
@mousedown.stop="focusWindow(win)"
|
||||
>
|
||||
<div class="window-titlebar"
|
||||
@mousedown.stop="startDrag($event, win)"
|
||||
>
|
||||
<span class="window-title" x-text="win.title"></span>
|
||||
<button class="window-close" @click.stop="closeWindow(win)">✕</button>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<iframe :src="win.url" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Cursor input overlay -->
|
||||
<div id="cursor-input" x-show="inputMode" @click.stop>
|
||||
<input
|
||||
id="cursor-input-field"
|
||||
type="text"
|
||||
x-model="cursorQuery"
|
||||
@keydown.enter="submitQuery"
|
||||
@keydown.escape="exitInputMode"
|
||||
placeholder="Type a command..."
|
||||
x-ref="cursorInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dock -->
|
||||
<div id="dock">
|
||||
<template x-for="app in installedApps" :key="app.id">
|
||||
<div class="dock-icon" @click="openApp(app)" :title="app.name">
|
||||
<span x-text="app.icon"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script src="js/os.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
ui/js/os.js
Normal file
140
ui/js/os.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('os', () => ({
|
||||
|
||||
// ── Canvas pan state ──────────────────────────────────────────────────
|
||||
offset: { x: 0, y: 0 },
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
|
||||
startPan(e) {
|
||||
if (e.target !== document.getElementById('canvas') &&
|
||||
!e.target.closest('#canvas') ||
|
||||
e.target.closest('.window')) return
|
||||
this.isPanning = true
|
||||
this.panStart = { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y }
|
||||
document.getElementById('canvas').classList.add('panning')
|
||||
},
|
||||
|
||||
pan(e) {
|
||||
if (!this.isPanning) return
|
||||
this.offset = { x: e.clientX - this.panStart.x, y: e.clientY - this.panStart.y }
|
||||
},
|
||||
|
||||
endPan() {
|
||||
this.isPanning = false
|
||||
document.getElementById('canvas').classList.remove('panning')
|
||||
},
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────
|
||||
windows: [],
|
||||
zCounter: 100,
|
||||
|
||||
openApp(app) {
|
||||
// Bring to front if already open
|
||||
const existing = this.windows.find(w => w.appId === app.id)
|
||||
if (existing) { this.focusWindow(existing); return }
|
||||
|
||||
this.windows.push({
|
||||
id: Date.now(),
|
||||
appId: app.id,
|
||||
title: app.name,
|
||||
url: app.url,
|
||||
x: 120 + Math.random() * 200,
|
||||
y: 80 + Math.random() * 100,
|
||||
w: 900,
|
||||
h: 600,
|
||||
z: ++this.zCounter,
|
||||
})
|
||||
},
|
||||
|
||||
focusWindow(win) {
|
||||
win.z = ++this.zCounter
|
||||
},
|
||||
|
||||
closeWindow(win) {
|
||||
this.windows = this.windows.filter(w => w.id !== win.id)
|
||||
},
|
||||
|
||||
// ── Window drag ───────────────────────────────────────────────────────
|
||||
dragging: null,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
|
||||
startDrag(e, win) {
|
||||
this.focusWindow(win)
|
||||
this.dragging = win
|
||||
this.dragStart = { x: e.clientX - win.x, y: e.clientY - win.y }
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!this.dragging) return
|
||||
this.dragging.x = e.clientX - this.dragStart.x
|
||||
this.dragging.y = e.clientY - this.dragStart.y
|
||||
}
|
||||
const onUp = () => {
|
||||
this.dragging = null
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
},
|
||||
|
||||
// ── Cursor input mode ─────────────────────────────────────────────────
|
||||
inputMode: false,
|
||||
cursorQuery: '',
|
||||
|
||||
handleKeydown(e) {
|
||||
// ⌘+Space or Ctrl+Space toggles input mode
|
||||
if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
this.inputMode ? this.exitInputMode() : this.enterInputMode()
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
enterInputMode() {
|
||||
this.inputMode = true
|
||||
this.cursorQuery = ''
|
||||
this.$nextTick(() => this.$refs.cursorInput?.focus())
|
||||
},
|
||||
|
||||
exitInputMode() {
|
||||
this.inputMode = false
|
||||
this.cursorQuery = ''
|
||||
},
|
||||
|
||||
submitQuery() {
|
||||
const query = this.cursorQuery.trim().toLowerCase()
|
||||
|
||||
// Built-in commands
|
||||
if (query === 'login') {
|
||||
this.openApp({ id: 'login', name: 'Login', url: '/login', icon: '🔐' })
|
||||
this.exitInputMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it matches an installed app name
|
||||
const app = this.installedApps.find(a => a.name.toLowerCase() === query)
|
||||
if (app) { this.openApp(app); this.exitInputMode(); return }
|
||||
|
||||
// Otherwise — send to LLM (placeholder)
|
||||
console.log('LLM query:', query)
|
||||
this.exitInputMode()
|
||||
},
|
||||
|
||||
// ── Installed apps ────────────────────────────────────────────────────
|
||||
// Loaded from crimata.json manifests on the server
|
||||
installedApps: [
|
||||
{ id: 'bio', name: 'Bio', icon: '🪪', url: '/apps/bio' },
|
||||
{ id: 'contacts', name: 'Contacts', icon: '👥', url: '/apps/contacts' },
|
||||
{ id: 'blog', name: 'Blog', icon: '📝', url: '/apps/blog' },
|
||||
],
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────
|
||||
init() {
|
||||
// Open bio full-screen on load
|
||||
const bio = this.installedApps.find(a => a.id === 'bio')
|
||||
if (bio) this.openApp(bio)
|
||||
}
|
||||
|
||||
}))
|
||||
})
|
||||
23
ui/styles/canvas.css
Normal file
23
ui/styles/canvas.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#canvas.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
24
ui/styles/cursor.css
Normal file
24
ui/styles/cursor.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#cursor-input {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
#cursor-input-field {
|
||||
width: 480px;
|
||||
padding: 14px 20px;
|
||||
font-size: 1.1rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
|
||||
outline: none;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
#cursor-input-field::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
34
ui/styles/dock.css
Normal file
34
ui/styles/dock.css
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#dock {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
padding: 10px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.dock-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dock-icon:hover {
|
||||
transform: translateY(-8px) scale(1.1);
|
||||
background: rgba(255,255,255,0.35);
|
||||
}
|
||||
61
ui/styles/window.css
Normal file
61
ui/styles/window.css
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
.window {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
min-width: 300px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.window-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.window-close:hover {
|
||||
background: #ff5f57;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.window-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-content iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
Loading…
Reference in a new issue