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