Initial commit -- just started to factor and implement python fast API backend
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"created": "2026-05-08T02:04:31.986Z",
|
||||||
|
"modified": "2026-05-08T02:04:42.961Z",
|
||||||
|
"objects": []
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Dashy — task dashboard</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
|
||||||
|
<script src="db.js"></script>
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<script type="text/babel" src="data.jsx"></script>
|
||||||
|
<script type="text/babel" src="components.jsx"></script>
|
||||||
|
<script type="text/babel" src="screens.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Dashy — main app (DB-backed via SQLite/WASM)
|
||||||
|
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"theme": "light",
|
||||||
|
"accent": "#2A6FDB",
|
||||||
|
"density": "cozy",
|
||||||
|
"showTags": true
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
|
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
|
||||||
|
|
||||||
|
function useDashyDB() {
|
||||||
|
const [ready, setReady] = React.useState(DashyDB.isReady);
|
||||||
|
const [, force] = React.useReducer(x => x + 1, 0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!DashyDB.isReady) DashyDB.init().then(() => setReady(true));
|
||||||
|
const off = DashyDB.subscribe(() => force());
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
const ready = useDashyDB();
|
||||||
|
const [authed, setAuthed] = React.useState(false);
|
||||||
|
const [meId, setMeId] = React.useState('rod');
|
||||||
|
const [tab, setTab] = React.useState('overview');
|
||||||
|
const [adding, setAdding] = React.useState(null);
|
||||||
|
const [openTaskId, setOpenTaskId] = React.useState(null);
|
||||||
|
const [showLogs, setShowLogs] = React.useState(false);
|
||||||
|
const [showSettings, setShowSettings] = React.useState(false);
|
||||||
|
const [showDB, setShowDB] = React.useState(false);
|
||||||
|
const [headsUp, setHeadsUp] = React.useState([
|
||||||
|
{ id: 'h1', kind: 'unsuccessful', taskId: 't7',
|
||||||
|
title: 'WO #2188 auto-marked Unsuccessful',
|
||||||
|
sub: 'Two missed bookings — assigned to Kirra for review' },
|
||||||
|
{ id: 'h2', kind: 'billing', taskId: 't4',
|
||||||
|
title: 'Form response switched to Billing',
|
||||||
|
sub: 'K. Wynne · originally Service Booking' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!ready) return <BootSplash />;
|
||||||
|
|
||||||
|
const tasks = DashyDB.listTasks();
|
||||||
|
const audit = DashyDB.listAudit();
|
||||||
|
const dbUsers = DashyDB.listUsers();
|
||||||
|
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
|
||||||
|
const merge = (id) => {
|
||||||
|
const base = findUser(id); if (!base && !userMap[id]) return null;
|
||||||
|
const live = userMap[id] || {};
|
||||||
|
if (!base) {
|
||||||
|
// user added at runtime
|
||||||
|
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
|
||||||
|
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
||||||
|
}
|
||||||
|
return { ...base, name: live.name || base.name, role: live.role || base.role,
|
||||||
|
photo: live.photo || null, account_type: live.account_type || 'standard' };
|
||||||
|
};
|
||||||
|
const me = merge(meId);
|
||||||
|
const isAdmin = me && me.account_type === 'admin';
|
||||||
|
const openTask = tasks.find(x => x.id === openTaskId);
|
||||||
|
|
||||||
|
const handleLogin = (id) => {
|
||||||
|
setMeId(id); setAuthed(true);
|
||||||
|
DashyDB.addAudit({
|
||||||
|
actor: id, action: 'login',
|
||||||
|
summary: (merge(id) || {}).name + ' signed in',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTask = ({ title, description, assignee, priority }) => {
|
||||||
|
const id = 't_' + Date.now().toString(36);
|
||||||
|
const at = new Date('2026-05-08T10:30:00').toISOString();
|
||||||
|
DashyDB.createTask({
|
||||||
|
id, title, description, assignee, priority,
|
||||||
|
addedBy: meId, source: 'manual', status: 'open', addedAt: at, tags: []
|
||||||
|
});
|
||||||
|
DashyDB.addAudit({
|
||||||
|
at, actor: meId, action: 'task_created',
|
||||||
|
summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name,
|
||||||
|
target: id
|
||||||
|
});
|
||||||
|
setAdding(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveTask = (taskId, toUserId) => DashyDB.moveTask(taskId, toUserId, meId);
|
||||||
|
const setPriority = (taskId, p) => DashyDB.setPriority(taskId, p);
|
||||||
|
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
|
||||||
|
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
|
||||||
|
|
||||||
|
if (!authed) return <LoginScreen onLogin={handleLogin} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<TopBar
|
||||||
|
me={me}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
tab={tab}
|
||||||
|
setTab={setTab}
|
||||||
|
onAdd={() => setAdding(meId)}
|
||||||
|
onLogs={() => setShowLogs(true)}
|
||||||
|
onProfile={() => setShowSettings(true)}
|
||||||
|
onDB={() => setShowDB(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<OverviewScreen
|
||||||
|
tasks={tasks} density={t.density}
|
||||||
|
onOpen={(task) => setOpenTaskId(task.id)}
|
||||||
|
onAddFor={(uid) => setAdding(uid)}
|
||||||
|
onMoveTask={moveTask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab !== 'overview' && (
|
||||||
|
<UserScreen
|
||||||
|
user={merge(tab)} tasks={tasks} density={t.density}
|
||||||
|
onOpen={(task) => setOpenTaskId(task.id)}
|
||||||
|
onAddFor={(uid) => setAdding(uid)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} />
|
||||||
|
{openTask && (
|
||||||
|
<TaskDetail task={openTask} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} />
|
||||||
|
)}
|
||||||
|
{showLogs && (
|
||||||
|
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
|
||||||
|
<AuditScreen entries={audit} onOpen={openTaskFromAnywhere} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{showSettings && (
|
||||||
|
<SettingsScreen
|
||||||
|
user={me}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onSave={(edits) => {
|
||||||
|
DashyDB.updateUser(meId, edits);
|
||||||
|
DashyDB.addAudit({ actor: meId, action: 'profile_updated', summary: 'Updated profile details' });
|
||||||
|
}}
|
||||||
|
onLogout={() => { setShowSettings(false); setAuthed(false); }}
|
||||||
|
onSwitchUser={(id) => { setMeId(id); setShowSettings(false); }}
|
||||||
|
onCreateUser={(u) => {
|
||||||
|
const id = DashyDB.createUser(u);
|
||||||
|
DashyDB.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id });
|
||||||
|
}}
|
||||||
|
onDeleteUser={(id) => {
|
||||||
|
const u = userMap[id];
|
||||||
|
DashyDB.deleteUser(id);
|
||||||
|
DashyDB.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null });
|
||||||
|
}}
|
||||||
|
onUpdateUserRole={(id, edits) => {
|
||||||
|
DashyDB.updateUser(id, edits);
|
||||||
|
DashyDB.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDB && isAdmin && <DatabaseInspector onClose={() => setShowDB(false)} />}
|
||||||
|
|
||||||
|
<DashyTweaks t={t} setTweak={setTweak} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BootSplash() {
|
||||||
|
return (
|
||||||
|
<div className="boot">
|
||||||
|
<div className="boot__pulse" />
|
||||||
|
<div className="boot__label mono">opening dashy.db…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashyTweaks({ t, setTweak }) {
|
||||||
|
return (
|
||||||
|
<TweaksPanel title="Tweaks">
|
||||||
|
<TweakSection title="Appearance">
|
||||||
|
<TweakRadio label="Theme" value={t.theme}
|
||||||
|
options={[{value:'light',label:'Light'},{value:'dark',label:'Dark'}]}
|
||||||
|
onChange={v => setTweak('theme', v)} />
|
||||||
|
<TweakColor label="Accent" value={t.accent} options={ACCENTS} onChange={v => setTweak('accent', v)} />
|
||||||
|
<TweakRadio label="Density" value={t.density}
|
||||||
|
options={[{value:'compact',label:'Compact'},{value:'cozy',label:'Cozy'}]}
|
||||||
|
onChange={v => setTweak('density', v)} />
|
||||||
|
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
|
||||||
|
</TweakSection>
|
||||||
|
<TweakSection title="Database">
|
||||||
|
<TweakButton label="Reset SQLite" onClick={() => { if (confirm('Wipe and reseed dashy.db?')) DashyDB.reset(); }}>
|
||||||
|
Reset
|
||||||
|
</TweakButton>
|
||||||
|
</TweakSection>
|
||||||
|
</TweaksPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeBridge() {
|
||||||
|
const [t] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = t.theme;
|
||||||
|
document.documentElement.style.setProperty('--accent', t.accent);
|
||||||
|
document.documentElement.dataset.showTags = t.showTags ? 'on' : 'off';
|
||||||
|
}, [t.theme, t.accent, t.showTags]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(<><ThemeBridge /><App /></>);
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
# SECRET_KEY should be in an environment variable in production
|
||||||
|
SECRET_KEY = "your-secret-key-change-this-in-production"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def verify_password(plain_password, hashed_password):
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password):
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Use an environment variable for the DB path, or default to local dashy.db
|
||||||
|
# On CIFS mounts, SQLite locking will fail, so we recommend a local path.
|
||||||
|
DB_PATH = os.getenv("DASHY_DB_PATH", "./dashy.db")
|
||||||
|
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False, "timeout": 30}
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from . import models, schemas, auth, database
|
||||||
|
from .database import engine, get_db
|
||||||
|
|
||||||
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Dashy API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # In production, specify your frontend URL
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/token", response_model=schemas.Token)
|
||||||
|
async def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(models.User).filter(models.User.id == form_data.id).first()
|
||||||
|
if not user or not auth.verify_password(form_data.password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token = auth.create_access_token(data={"sub": user.id})
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@app.get("/users", response_model=List[schemas.User])
|
||||||
|
def read_users(db: Session = Depends(get_db)):
|
||||||
|
return db.query(models.User).all()
|
||||||
|
|
||||||
|
@app.get("/tasks", response_model=List[schemas.Task])
|
||||||
|
def read_tasks(db: Session = Depends(get_db)):
|
||||||
|
return db.query(models.Task).all()
|
||||||
|
|
||||||
|
@app.post("/tasks", response_model=schemas.Task)
|
||||||
|
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
|
||||||
|
task_id = task.id or f"t_{uuid.uuid4().hex[:8]}"
|
||||||
|
db_task = models.Task(
|
||||||
|
id=task_id,
|
||||||
|
title=task.title,
|
||||||
|
description=task.description,
|
||||||
|
assignee_id=task.assignee_id,
|
||||||
|
added_by=task.added_by,
|
||||||
|
priority=task.priority,
|
||||||
|
source=task.source,
|
||||||
|
status=task.status,
|
||||||
|
due_at=task.due_at,
|
||||||
|
reminder_at=task.reminder_at
|
||||||
|
)
|
||||||
|
db.add(db_task)
|
||||||
|
|
||||||
|
for tag_name in task.tags:
|
||||||
|
tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first()
|
||||||
|
if not tag:
|
||||||
|
tag = models.Tag(tag=tag_name)
|
||||||
|
db.add(tag)
|
||||||
|
db_task.tags.append(tag)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_task)
|
||||||
|
return db_task
|
||||||
|
|
||||||
|
@app.patch("/tasks/{task_id}", response_model=schemas.Task)
|
||||||
|
def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db)):
|
||||||
|
db_task = db.query(models.Task).filter(models.Task.id == task_id).first()
|
||||||
|
if not db_task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
update_data = task_update.dict(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(db_task, key, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_task)
|
||||||
|
return db_task
|
||||||
|
|
||||||
|
@app.get("/audit", response_model=List[schemas.AuditLog])
|
||||||
|
def read_audit(db: Session = Depends(get_db)):
|
||||||
|
return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
|
||||||
|
|
||||||
|
@app.post("/audit", response_model=schemas.AuditLog)
|
||||||
|
def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db)):
|
||||||
|
audit_id = f"a_{uuid.uuid4().hex[:8]}"
|
||||||
|
db_audit = models.AuditLog(
|
||||||
|
id=audit_id,
|
||||||
|
actor=audit.actor,
|
||||||
|
action=audit.action,
|
||||||
|
summary=audit.summary,
|
||||||
|
target=audit.target
|
||||||
|
)
|
||||||
|
db.add(db_audit)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_audit)
|
||||||
|
return db_audit
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
task_tags = Table(
|
||||||
|
"task_tags",
|
||||||
|
Base.metadata,
|
||||||
|
Column("task_id", String, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("tag", String, ForeignKey("tags.tag", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
role = Column(String, nullable=False)
|
||||||
|
hue = Column(Integer, nullable=False)
|
||||||
|
initials = Column(String, nullable=False)
|
||||||
|
email = Column(String)
|
||||||
|
phone = Column(String)
|
||||||
|
photo = Column(String)
|
||||||
|
password_hash = Column(String)
|
||||||
|
account_type = Column(String, nullable=False, default="standard")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
tasks = relationship("Task", back_populates="assignee", foreign_keys="Task.assignee_id")
|
||||||
|
notes = relationship("TaskNote", back_populates="author")
|
||||||
|
|
||||||
|
class Task(Base):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
assignee_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||||
|
added_by = Column(String, nullable=False)
|
||||||
|
priority = Column(String, nullable=False) # low, med, high
|
||||||
|
source = Column(String, nullable=False) # manual, imessage, email, automation
|
||||||
|
status = Column(String, nullable=False, default="open")
|
||||||
|
added_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
due_at = Column(DateTime(timezone=True))
|
||||||
|
reminder_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id])
|
||||||
|
tags = relationship("Tag", secondary=task_tags, back_populates="tasks")
|
||||||
|
notes = relationship("TaskNote", back_populates="task", cascade="all, delete")
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
__tablename__ = "tags"
|
||||||
|
tag = Column(String, primary_key=True)
|
||||||
|
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
|
||||||
|
|
||||||
|
class TaskNote(Base):
|
||||||
|
__tablename__ = "task_notes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
task_id = Column(String, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
author_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||||
|
body = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
task = relationship("Task", back_populates="notes")
|
||||||
|
author = relationship("User", back_populates="notes")
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
actor = Column(String, nullable=False)
|
||||||
|
action = Column(String, nullable=False)
|
||||||
|
summary = Column(String, nullable=False)
|
||||||
|
target = Column(String)
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
||||||
|
device = Column(String, nullable=False)
|
||||||
|
location = Column(String)
|
||||||
|
last_active = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
pydantic-settings
|
||||||
|
python-jose[cryptography]
|
||||||
|
passlib
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
role: str
|
||||||
|
hue: int
|
||||||
|
initials: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
photo: Optional[str] = None
|
||||||
|
account_type: str = "standard"
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class User(UserBase):
|
||||||
|
created_at: datetime
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TaskNoteBase(BaseModel):
|
||||||
|
body: str
|
||||||
|
|
||||||
|
class TaskNoteCreate(TaskNoteBase):
|
||||||
|
task_id: str
|
||||||
|
author_id: str
|
||||||
|
|
||||||
|
class TaskNote(TaskNoteBase):
|
||||||
|
id: int
|
||||||
|
task_id: str
|
||||||
|
author_id: str
|
||||||
|
created_at: datetime
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TagBase(BaseModel):
|
||||||
|
tag: str
|
||||||
|
|
||||||
|
class Tag(TagBase):
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TaskBase(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
assignee_id: str
|
||||||
|
added_by: str
|
||||||
|
priority: str
|
||||||
|
source: str
|
||||||
|
status: str = "open"
|
||||||
|
due_at: Optional[datetime] = None
|
||||||
|
reminder_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class TaskCreate(TaskBase):
|
||||||
|
id: Optional[str] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
|
||||||
|
class TaskUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
assignee_id: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
due_at: Optional[datetime] = None
|
||||||
|
reminder_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Task(TaskBase):
|
||||||
|
id: str
|
||||||
|
added_at: datetime
|
||||||
|
tags: List[Tag] = []
|
||||||
|
notes: List[TaskNote] = []
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class AuditLogBase(BaseModel):
|
||||||
|
actor: str
|
||||||
|
action: str
|
||||||
|
summary: str
|
||||||
|
target: Optional[str] = None
|
||||||
|
|
||||||
|
class AuditLog(AuditLogBase):
|
||||||
|
id: str
|
||||||
|
at: datetime
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class SessionBase(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
device: str
|
||||||
|
location: Optional[str] = None
|
||||||
|
|
||||||
|
class Session(SessionBase):
|
||||||
|
id: int
|
||||||
|
last_active: datetime
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .database import SessionLocal, engine
|
||||||
|
from . import models, auth
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Import data directly as if it were coming from data.jsx
|
||||||
|
USERS = [
|
||||||
|
{"id": "rod", "name": "Rod", "role": "Owner", "hue": 220, "initials": "R"},
|
||||||
|
{"id": "lani", "name": "Lani", "role": "Admin", "hue": 340, "initials": "L"},
|
||||||
|
{"id": "kirra", "name": "Kirra", "role": "Technician", "hue": 160, "initials": "K"},
|
||||||
|
{"id": "ayron", "name": "Ayron", "role": "Technician", "hue": 40, "initials": "A"},
|
||||||
|
]
|
||||||
|
|
||||||
|
SEED_TASKS = [
|
||||||
|
{ "id": 't1', "title": 'Call back Mrs. Patel re: Hilux service quote',
|
||||||
|
"description": 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
|
||||||
|
"assignee": 'lani', "addedBy": 'rod', "priority": 'high', "source": 'imessage',
|
||||||
|
"addedAt": '2026-05-08T08:42:00', "status": 'open',
|
||||||
|
"tags": ['quote'] },
|
||||||
|
{ "id": 't2', "title": 'Email #3814 → Workorder #2207',
|
||||||
|
"description": 'Auto-converted from inbox. Tell customer ETA of Friday.',
|
||||||
|
"assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'email',
|
||||||
|
"addedAt": '2026-05-08T07:15:00', "status": 'open',
|
||||||
|
"tags": ['WO #2207'] },
|
||||||
|
{ "id": 't3', "title": 'Reorder coolant — 5L × 4',
|
||||||
|
"description": 'Stock card ran red on the morning sweep.',
|
||||||
|
"assignee": 'lani', "addedBy": 'kirra', "priority": 'low', "source": 'manual',
|
||||||
|
"addedAt": '2026-05-07T16:02:00', "status": 'open', "tags": [] },
|
||||||
|
{ "id": 't4', "title": 'Form response from "K. Wynne" auto-switched to Billing',
|
||||||
|
"description": 'Originally captured as Service Booking — heads up.',
|
||||||
|
"assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'automation',
|
||||||
|
"addedAt": '2026-05-08T09:50:00', "status": 'billing',
|
||||||
|
"tags": ['form'] },
|
||||||
|
{ "id": 't5', "title": 'Diagnose intermittent misfire — Camry, WO #2199',
|
||||||
|
"description": 'Cust says it stutters between 60–80km/h once warm.',
|
||||||
|
"assignee": 'kirra', "addedBy": 'rod', "priority": 'high', "source": 'manual',
|
||||||
|
"addedAt": '2026-05-08T07:50:00', "status": 'open',
|
||||||
|
"tags": ['WO #2199'] },
|
||||||
|
{ "id": 't6', "title": 'Sign off on Ayron\'s brake job — Forester',
|
||||||
|
"description": 'Final torque check + road test before pickup at 3pm.',
|
||||||
|
"assignee": 'kirra', "addedBy": 'ayron', "priority": 'med', "source": 'manual',
|
||||||
|
"addedAt": '2026-05-08T09:05:00', "status": 'open', "tags": [] },
|
||||||
|
{ "id": 't7', "title": 'WO #2188 auto-marked Unsuccessful',
|
||||||
|
"description": 'Customer no-show twice — please review and decide on next step.',
|
||||||
|
"assignee": 'kirra', "addedBy": 'system', "priority": 'high', "source": 'automation',
|
||||||
|
"addedAt": '2026-05-08T06:00:00', "status": 'unsuccessful',
|
||||||
|
"tags": ['WO #2188'] },
|
||||||
|
{ "id": 't8', "title": 'Replace front pads + rotors — Forester',
|
||||||
|
"description": 'Parts arrived yesterday. Bay 2 from 10am.',
|
||||||
|
"assignee": 'ayron', "addedBy": 'kirra', "priority": 'med', "source": 'manual',
|
||||||
|
"addedAt": '2026-05-08T08:00:00', "status": 'open',
|
||||||
|
"tags": ['WO #2201'] },
|
||||||
|
{ "id": 't9', "title": 'Tidy bay 3 + sweep before lunch',
|
||||||
|
"description": '',
|
||||||
|
"assignee": 'ayron', "addedBy": 'rod', "priority": 'low', "source": 'imessage',
|
||||||
|
"addedAt": '2026-05-08T09:30:00', "status": 'open', "tags": [] },
|
||||||
|
{ "id": 't10', "title": 'Pickup parts from Repco @ 11:30',
|
||||||
|
"description": 'Two boxes for WO #2199 + an oil filter for stock.',
|
||||||
|
"assignee": 'ayron', "addedBy": 'lani', "priority": 'med', "source": 'manual',
|
||||||
|
"addedAt": '2026-05-08T08:20:00', "status": 'open', "tags": [] },
|
||||||
|
{ "id": 't11', "title": 'Approve quote on Job #2207 ($1,840)',
|
||||||
|
"description": 'Lani flagged this — needs your sign-off before sending.',
|
||||||
|
"assignee": 'rod', "addedBy": 'lani', "priority": 'high', "source": 'manual',
|
||||||
|
"addedAt": '2026-05-08T09:12:00', "status": 'open',
|
||||||
|
"tags": ['quote', 'WO #2207'] },
|
||||||
|
{ "id": 't12', "title": 'Review weekly automation report',
|
||||||
|
"description": '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
|
||||||
|
"assignee": 'rod', "addedBy": 'system', "priority": 'low', "source": 'automation',
|
||||||
|
"addedAt": '2026-05-08T06:00:00', "status": 'open', "tags": [] },
|
||||||
|
]
|
||||||
|
|
||||||
|
SEED_AUDIT = [
|
||||||
|
{ "id": 'a1', "at": '2026-05-08T09:50:00', "actor": 'system', "action": 'form_rerouted',
|
||||||
|
"summary": 'Form from K. Wynne auto-switched: Service Booking → Billing form',
|
||||||
|
"target": 't4' },
|
||||||
|
{ "id": 'a2', "at": '2026-05-08T09:30:00', "actor": 'rod', "action": 'task_created',
|
||||||
|
"summary": 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
|
||||||
|
"target": 't9' },
|
||||||
|
{ "id": 'a3', "at": '2026-05-08T09:12:00', "actor": 'lani', "action": 'task_assigned',
|
||||||
|
"summary": 'Assigned "Approve quote on Job #2207" to ROD',
|
||||||
|
"target": 't11' },
|
||||||
|
{ "id": 'a4', "at": '2026-05-08T09:05:00', "actor": 'ayron', "action": 'task_moved',
|
||||||
|
"summary": 'Moved "Sign off on brake job" from Ayron → Kirra',
|
||||||
|
"target": 't6' },
|
||||||
|
]
|
||||||
|
|
||||||
|
def seed_db():
|
||||||
|
# Create tables
|
||||||
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# Add Users
|
||||||
|
for u in USERS:
|
||||||
|
is_admin = u['id'] in ['rod', 'ayron']
|
||||||
|
db_user = models.User(
|
||||||
|
id=u['id'],
|
||||||
|
name=u['name'],
|
||||||
|
role=u['role'],
|
||||||
|
hue=u['hue'],
|
||||||
|
initials=u['initials'],
|
||||||
|
email=f"{u['id']}@murchison-auto.co",
|
||||||
|
phone="+64 27 555 0184",
|
||||||
|
password_hash=auth.get_password_hash("password123"), # Default password
|
||||||
|
account_type='admin' if is_admin else 'standard'
|
||||||
|
)
|
||||||
|
db.merge(db_user)
|
||||||
|
|
||||||
|
# Add Tasks
|
||||||
|
for t in SEED_TASKS:
|
||||||
|
db_task = models.Task(
|
||||||
|
id=t['id'],
|
||||||
|
title=t['title'],
|
||||||
|
description=t['description'],
|
||||||
|
assignee_id=t['assignee'],
|
||||||
|
added_by=t['addedBy'],
|
||||||
|
priority=t['priority'],
|
||||||
|
source=t['source'],
|
||||||
|
status=t['status'],
|
||||||
|
added_at=datetime.fromisoformat(t['addedAt'])
|
||||||
|
)
|
||||||
|
db.merge(db_task)
|
||||||
|
|
||||||
|
# Add tags
|
||||||
|
for tag_name in t.get('tags', []):
|
||||||
|
tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first()
|
||||||
|
if not tag:
|
||||||
|
tag = models.Tag(tag=tag_name)
|
||||||
|
db.add(tag)
|
||||||
|
if tag not in db_task.tags:
|
||||||
|
db_task.tags.append(tag)
|
||||||
|
|
||||||
|
# Add Audit
|
||||||
|
for a in SEED_AUDIT:
|
||||||
|
db_audit = models.AuditLog(
|
||||||
|
id=a['id'],
|
||||||
|
at=datetime.fromisoformat(a['at']),
|
||||||
|
actor=a['actor'],
|
||||||
|
action=a['action'],
|
||||||
|
summary=a['summary'],
|
||||||
|
target=a['target']
|
||||||
|
)
|
||||||
|
db.merge(db_audit)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_db()
|
||||||
|
print("Database seeded!")
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
// Shared primitives for Dashy
|
||||||
|
|
||||||
|
const USERS = [
|
||||||
|
{ id: 'lani', name: 'Lani', role: 'Front of house', hue: 28, initials: 'LA' },
|
||||||
|
{ id: 'kirra', name: 'Kirra', role: 'Workshop lead', hue: 145, initials: 'KI' },
|
||||||
|
{ id: 'ayron', name: 'Ayron', role: 'Tech', hue: 215, initials: 'AY' },
|
||||||
|
{ id: 'rod', name: 'ROD', role: 'Owner', hue: 280, initials: 'RO' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIORITY = {
|
||||||
|
high: { label: 'High', color: 'var(--prio-high)', dot: 'var(--prio-high)' },
|
||||||
|
med: { label: 'Med', color: 'var(--prio-med)', dot: 'var(--prio-med)' },
|
||||||
|
low: { label: 'Low', color: 'var(--prio-low)', dot: 'var(--prio-low)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCES = {
|
||||||
|
manual: { label: 'Manual', glyph: '·' },
|
||||||
|
imessage: { label: 'iMessage', glyph: '✦' },
|
||||||
|
email: { label: 'Email', glyph: '✉' },
|
||||||
|
automation:{ label: 'Auto', glyph: '⚙' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function Avatar({ user, size = 28, ring = false }) {
|
||||||
|
if (!user) return null;
|
||||||
|
const s = { width: size, height: size, fontSize: Math.round(size * 0.42) };
|
||||||
|
if (user.photo) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={"avatar avatar--photo" + (ring ? " avatar--ring" : "")}
|
||||||
|
style={{
|
||||||
|
...s,
|
||||||
|
backgroundImage: `url(${user.photo})`,
|
||||||
|
boxShadow: ring ? `0 0 0 2px var(--bg), 0 0 0 3px oklch(70% 0.10 ${user.hue})` : undefined,
|
||||||
|
}}
|
||||||
|
title={user.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={"avatar" + (ring ? " avatar--ring" : "")}
|
||||||
|
style={{
|
||||||
|
...s,
|
||||||
|
background: `oklch(94% 0.04 ${user.hue})`,
|
||||||
|
color: `oklch(32% 0.10 ${user.hue})`,
|
||||||
|
boxShadow: ring ? `0 0 0 2px var(--bg), 0 0 0 3px oklch(70% 0.10 ${user.hue})` : undefined,
|
||||||
|
}}
|
||||||
|
title={user.name}
|
||||||
|
>
|
||||||
|
{user.initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityDot({ priority, withLabel = false }) {
|
||||||
|
const p = PRIORITY[priority];
|
||||||
|
if (!p) return null;
|
||||||
|
return (
|
||||||
|
<span className="prio">
|
||||||
|
<span className="prio__dot" style={{ background: p.dot }} />
|
||||||
|
{withLabel && <span className="prio__label">{p.label}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceTag({ source }) {
|
||||||
|
const s = SOURCES[source];
|
||||||
|
if (!s || source === 'manual') return null;
|
||||||
|
return (
|
||||||
|
<span className="source-tag" data-source={source}>
|
||||||
|
<span className="source-tag__glyph">{s.glyph}</span>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(iso) {
|
||||||
|
const now = new Date('2026-05-08T10:30:00');
|
||||||
|
const then = new Date(iso);
|
||||||
|
const diff = (now - then) / 1000;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
|
||||||
|
if (diff < 86400*7) return Math.floor(diff/86400) + 'd ago';
|
||||||
|
return then.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
||||||
|
' · ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUser(id) { return USERS.find(u => u.id === id); }
|
||||||
|
|
||||||
|
function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd }) {
|
||||||
|
const author = findUser(task.addedBy);
|
||||||
|
const isAuto = task.status === 'unsuccessful' || task.status === 'billing';
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")}
|
||||||
|
data-density={density}
|
||||||
|
data-priority={task.priority}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/dashy-task', task.id);
|
||||||
|
onDragStart && onDragStart(task);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||||
|
onClick={() => onOpen && onOpen(task)}
|
||||||
|
data-comment-anchor={"task-" + task.id}
|
||||||
|
>
|
||||||
|
<span className="card__grip" aria-hidden="true">
|
||||||
|
<span /><span /><span /><span /><span /><span />
|
||||||
|
</span>
|
||||||
|
<header className="card__head">
|
||||||
|
<h3 className="card__title">{task.title}</h3>
|
||||||
|
<PriorityDot priority={task.priority} />
|
||||||
|
</header>
|
||||||
|
{task.description && (
|
||||||
|
<p className="card__desc">{task.description}</p>
|
||||||
|
)}
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<div className="card__tags">
|
||||||
|
{task.tags.map(t => <span key={t} className="chip">{t}</span>)}
|
||||||
|
{task.source && task.source !== 'manual' && <SourceTag source={task.source} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAuto && (
|
||||||
|
<div className="card__alert">
|
||||||
|
{task.status === 'unsuccessful'
|
||||||
|
? 'Auto-marked unsuccessful — needs review'
|
||||||
|
: 'Switched to billing form'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer className="card__foot">
|
||||||
|
<span className="card__author">
|
||||||
|
<Avatar user={author} size={20} />
|
||||||
|
<span>{author && author.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="card__time">{relTime(task.addedAt)}</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBtn({ children, onClick, label, variant = 'ghost' }) {
|
||||||
|
return (
|
||||||
|
<button className={"icon-btn icon-btn--" + variant} onClick={onClick} aria-label={label} title={label}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline icons
|
||||||
|
const Icon = {
|
||||||
|
Plus: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 3v10M3 8h10" strokeLinecap="round"/></svg>,
|
||||||
|
Bell: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M4 7a4 4 0 1 1 8 0v3l1 2H3l1-2V7Z" strokeLinejoin="round"/><path d="M6.5 13a1.5 1.5 0 0 0 3 0"/></svg>,
|
||||||
|
Search: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3" strokeLinecap="round"/></svg>,
|
||||||
|
Logs: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 4h10M3 8h10M3 12h6" strokeLinecap="round"/></svg>,
|
||||||
|
Close: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round"/></svg>,
|
||||||
|
Check: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
Arrow: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M3 8h10m-4-4 4 4-4 4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
Dot: () => <svg viewBox="0 0 16 16" width="14" height="14"><circle cx="8" cy="8" r="2.5" fill="currentColor"/></svg>,
|
||||||
|
Pin: () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M10 2 6 6H3l4 4-3 4 4-3 4 4v-3l4-4-2-2-2-4Z" strokeLinejoin="round"/></svg>,
|
||||||
|
iMessage: () => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M2.5 7.5c0-2.8 2.5-5 5.5-5s5.5 2.2 5.5 5-2.5 5-5.5 5c-.6 0-1.2-.1-1.7-.2L3 13.5l.7-2.5c-.7-.9-1.2-1.9-1.2-3Z" strokeLinejoin="round"/></svg>,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
USERS, PRIORITY, SOURCES,
|
||||||
|
Avatar, PriorityDot, SourceTag, TaskCard, IconBtn, Icon,
|
||||||
|
relTime, fmtDateTime, findUser,
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
##### Conditions
|
||||||
|
|
||||||
|
- Multi User With auth per user
|
||||||
|
- Audit Logs for all actions (moving tasks, editing tasks, adding tasks and user auth)
|
||||||
|
- Create and mv tasks between users
|
||||||
|
- Notify User when new "task" is assigned to them
|
||||||
|
- iMessage --> Task
|
||||||
|
- Include date when tasks was added and who by
|
||||||
|
- Possibly a priority stamp? This would be on how important the task was needing to be completed....
|
||||||
|
- Heads up display of when a task was automatically changed to "Unsuccessful"
|
||||||
|
- Heads up display of when automatic form response is changed to "Billing form"
|
||||||
|
- Easy way to add notes and reminders.
|
||||||
|
|
||||||
|
##### What do I want this thing to do
|
||||||
|
|
||||||
|
- I want to create task for Users to-do (like a reminder / to-do / task list)
|
||||||
|
- Pop up notifications for when a reminder comes through to you??? >>>> Idk if this is going to be a native windows application for something???
|
||||||
|
- I think it would be handy to not only have this as a task "dashboard" but also as a way to keep track of what we have automated.
|
||||||
|
- it would be nice if when a task is taken from emails and added to whichever queue we decide to add it too -- it leaves a task in the dashy to tell staff (Lani in particular) "Hey email such and such has just been converted to workorder # (and have whatever the workorder number is)
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
|
##### iMessage --> Task ? What I mean
|
||||||
|
|
||||||
|
So what do I mean when I say "iMessage too task"
|
||||||
|
|
||||||
|
- What I mean by this is so that ROD can simply pick up his phone and ask siri or meta to send a message to "X" -- "X" being whatever he/we decide to name the contact for the openclaw agent.
|
||||||
|
- This message could be something allong the lines of "Create new note for Job 'Y' say 'blah blah blah'" This would then send that message to a Molty which would then create a new task (Specifically in l Lani's tasks list) to-do whatever ROD had sent to the agent.
|
||||||
|
|
||||||
|
##### Design
|
||||||
|
|
||||||
|
Dashboard Home screen will have 5 "tabs" at the top of the screen
|
||||||
|
|
||||||
|
- Overview (This will have all users coloums and tasks)
|
||||||
|
- Lani
|
||||||
|
- Kirra
|
||||||
|
- Ayron
|
||||||
|
- ROD
|
||||||
|
|
||||||
|
|
||||||
|
In overview tab there will be names (Lani, kirra, Aryon, ROD) beside each name will be a circular "+" icon to add tasks to each user. just below it. Each name will have a coloum with cards
|
||||||
|
These cards will be tasks for each user.
|
||||||
|
The cards will include
|
||||||
|
- the task that needs to be done (So a description)
|
||||||
|
- Which user added it (As each user wil have to log into their account)
|
||||||
|
- The time and date it was added.
|
||||||
|
Thats pretty much the sum of these cards -- its a to-do list basically or a tasks list
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Seed data for Dashy prototype
|
||||||
|
|
||||||
|
const SEED_TASKS = [
|
||||||
|
// Lani
|
||||||
|
{ id: 't1', title: 'Call back Mrs. Patel re: Hilux service quote',
|
||||||
|
description: 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
|
||||||
|
assignee: 'lani', addedBy: 'rod', priority: 'high', source: 'imessage',
|
||||||
|
addedAt: '2026-05-08T08:42:00', status: 'open',
|
||||||
|
tags: ['quote'] },
|
||||||
|
{ id: 't2', title: 'Email #3814 → Workorder #2207',
|
||||||
|
description: 'Auto-converted from inbox. Tell customer ETA of Friday.',
|
||||||
|
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'email',
|
||||||
|
addedAt: '2026-05-08T07:15:00', status: 'open',
|
||||||
|
tags: ['WO #2207'] },
|
||||||
|
{ id: 't3', title: 'Reorder coolant — 5L × 4',
|
||||||
|
description: 'Stock card ran red on the morning sweep.',
|
||||||
|
assignee: 'lani', addedBy: 'kirra', priority: 'low', source: 'manual',
|
||||||
|
addedAt: '2026-05-07T16:02:00', status: 'open' },
|
||||||
|
{ id: 't4', title: 'Form response from "K. Wynne" auto-switched to Billing',
|
||||||
|
description: 'Originally captured as Service Booking — heads up.',
|
||||||
|
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'automation',
|
||||||
|
addedAt: '2026-05-08T09:50:00', status: 'billing',
|
||||||
|
tags: ['form'] },
|
||||||
|
|
||||||
|
// Kirra
|
||||||
|
{ id: 't5', title: 'Diagnose intermittent misfire — Camry, WO #2199',
|
||||||
|
description: 'Cust says it stutters between 60–80km/h once warm.',
|
||||||
|
assignee: 'kirra', addedBy: 'rod', priority: 'high', source: 'manual',
|
||||||
|
addedAt: '2026-05-08T07:50:00', status: 'open',
|
||||||
|
tags: ['WO #2199'] },
|
||||||
|
{ id: 't6', title: 'Sign off on Ayron\'s brake job — Forester',
|
||||||
|
description: 'Final torque check + road test before pickup at 3pm.',
|
||||||
|
assignee: 'kirra', addedBy: 'ayron', priority: 'med', source: 'manual',
|
||||||
|
addedAt: '2026-05-08T09:05:00', status: 'open' },
|
||||||
|
{ id: 't7', title: 'WO #2188 auto-marked Unsuccessful',
|
||||||
|
description: 'Customer no-show twice — please review and decide on next step.',
|
||||||
|
assignee: 'kirra', addedBy: 'system', priority: 'high', source: 'automation',
|
||||||
|
addedAt: '2026-05-08T06:00:00', status: 'unsuccessful',
|
||||||
|
tags: ['WO #2188'] },
|
||||||
|
|
||||||
|
// Ayron
|
||||||
|
{ id: 't8', title: 'Replace front pads + rotors — Forester',
|
||||||
|
description: 'Parts arrived yesterday. Bay 2 from 10am.',
|
||||||
|
assignee: 'ayron', addedBy: 'kirra', priority: 'med', source: 'manual',
|
||||||
|
addedAt: '2026-05-08T08:00:00', status: 'open',
|
||||||
|
tags: ['WO #2201'] },
|
||||||
|
{ id: 't9', title: 'Tidy bay 3 + sweep before lunch',
|
||||||
|
description: '',
|
||||||
|
assignee: 'ayron', addedBy: 'rod', priority: 'low', source: 'imessage',
|
||||||
|
addedAt: '2026-05-08T09:30:00', status: 'open' },
|
||||||
|
{ id: 't10', title: 'Pickup parts from Repco @ 11:30',
|
||||||
|
description: 'Two boxes for WO #2199 + an oil filter for stock.',
|
||||||
|
assignee: 'ayron', addedBy: 'lani', priority: 'med', source: 'manual',
|
||||||
|
addedAt: '2026-05-08T08:20:00', status: 'open' },
|
||||||
|
|
||||||
|
// ROD
|
||||||
|
{ id: 't11', title: 'Approve quote on Job #2207 ($1,840)',
|
||||||
|
description: 'Lani flagged this — needs your sign-off before sending.',
|
||||||
|
assignee: 'rod', addedBy: 'lani', priority: 'high', source: 'manual',
|
||||||
|
addedAt: '2026-05-08T09:12:00', status: 'open',
|
||||||
|
tags: ['quote', 'WO #2207'] },
|
||||||
|
{ id: 't12', title: 'Review weekly automation report',
|
||||||
|
description: '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
|
||||||
|
assignee: 'rod', addedBy: 'system', priority: 'low', source: 'automation',
|
||||||
|
addedAt: '2026-05-08T06:00:00', status: 'open' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEED_AUDIT = [
|
||||||
|
{ id: 'a1', at: '2026-05-08T09:50:00', actor: 'system', action: 'form_rerouted',
|
||||||
|
summary: 'Form from K. Wynne auto-switched: Service Booking → Billing form',
|
||||||
|
target: 't4' },
|
||||||
|
{ id: 'a2', at: '2026-05-08T09:30:00', actor: 'rod', action: 'task_created',
|
||||||
|
summary: 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
|
||||||
|
target: 't9' },
|
||||||
|
{ id: 'a3', at: '2026-05-08T09:12:00', actor: 'lani', action: 'task_assigned',
|
||||||
|
summary: 'Assigned "Approve quote on Job #2207" to ROD',
|
||||||
|
target: 't11' },
|
||||||
|
{ id: 'a4', at: '2026-05-08T09:05:00', actor: 'ayron', action: 'task_moved',
|
||||||
|
summary: 'Moved "Sign off on brake job" from Ayron → Kirra',
|
||||||
|
target: 't6' },
|
||||||
|
{ id: 'a5', at: '2026-05-08T08:42:00', actor: 'rod', action: 'task_created',
|
||||||
|
summary: 'Created task "Call back Mrs. Patel" for Lani via iMessage',
|
||||||
|
target: 't1' },
|
||||||
|
{ id: 'a6', at: '2026-05-08T08:20:00', actor: 'lani', action: 'task_created',
|
||||||
|
summary: 'Created task "Pickup parts from Repco" for Ayron',
|
||||||
|
target: 't10' },
|
||||||
|
{ id: 'a7', at: '2026-05-08T08:00:00', actor: 'kirra', action: 'task_created',
|
||||||
|
summary: 'Created task "Replace front pads + rotors" for Ayron',
|
||||||
|
target: 't8' },
|
||||||
|
{ id: 'a8', at: '2026-05-08T07:50:00', actor: 'rod', action: 'task_edited',
|
||||||
|
summary: 'Edited priority on "Diagnose intermittent misfire" — Med → High',
|
||||||
|
target: 't5' },
|
||||||
|
{ id: 'a9', at: '2026-05-08T07:15:00', actor: 'system', action: 'email_converted',
|
||||||
|
summary: 'Email #3814 converted to Workorder #2207 (assigned to Lani)',
|
||||||
|
target: 't2' },
|
||||||
|
{ id: 'a10', at: '2026-05-08T06:00:00', actor: 'system', action: 'task_unsuccessful',
|
||||||
|
summary: 'WO #2188 auto-marked Unsuccessful after 2 missed bookings',
|
||||||
|
target: 't7' },
|
||||||
|
{ id: 'a11', at: '2026-05-07T16:02:00', actor: 'kirra', action: 'task_created',
|
||||||
|
summary: 'Created task "Reorder coolant" for Lani',
|
||||||
|
target: 't3' },
|
||||||
|
{ id: 'a12', at: '2026-05-07T15:30:00', actor: 'rod', action: 'login',
|
||||||
|
summary: 'Signed in from MacBook · Christchurch',
|
||||||
|
target: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Per-task audit slices
|
||||||
|
const TASK_AUDIT = {
|
||||||
|
t1: [
|
||||||
|
{ at: '2026-05-08T08:42:00', actor: 'rod', action: 'created via iMessage',
|
||||||
|
detail: '"Hey, create new task for Lani — call back Mrs. Patel about the Hilux quote, she wants confirmation before she books"' },
|
||||||
|
{ at: '2026-05-08T08:43:00', actor: 'system', action: 'priority set to High', detail: 'Inferred from "wants confirmation"' },
|
||||||
|
{ at: '2026-05-08T08:45:00', actor: 'lani', action: 'opened', detail: '' },
|
||||||
|
],
|
||||||
|
t11: [
|
||||||
|
{ at: '2026-05-08T09:12:00', actor: 'lani', action: 'assigned to ROD', detail: 'flagged for sign-off before send' },
|
||||||
|
{ at: '2026-05-08T09:14:00', actor: 'lani', action: 'added note', detail: 'Customer wants the work done before Mother\'s Day weekend.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
window.SEED_TASKS = SEED_TASKS;
|
||||||
|
window.SEED_AUDIT = SEED_AUDIT;
|
||||||
|
window.TASK_AUDIT = TASK_AUDIT;
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// Dashy SQLite layer — uses sql.js (SQLite compiled to WASM)
|
||||||
|
// Database is initialized once, seeded from SEED_TASKS / SEED_AUDIT / USERS,
|
||||||
|
// persisted to localStorage on every write, and exposed as window.DashyDB.
|
||||||
|
|
||||||
|
window.DashyDB = (function () {
|
||||||
|
const LS_KEY = 'dashy.db.v1';
|
||||||
|
let db = null;
|
||||||
|
let SQL = null;
|
||||||
|
const listeners = new Set();
|
||||||
|
let ready = false;
|
||||||
|
const readyWaiters = [];
|
||||||
|
|
||||||
|
// -- schema -----------------------------------------------------------
|
||||||
|
const SCHEMA = `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
hue INTEGER NOT NULL,
|
||||||
|
initials TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
photo TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'standard' CHECK (account_type IN ('admin','standard')),
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
assignee_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
added_by TEXT NOT NULL,
|
||||||
|
priority TEXT NOT NULL CHECK (priority IN ('low','med','high')),
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('manual','imessage','email','automation')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
due_at TEXT,
|
||||||
|
reminder_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
|
||||||
|
CREATE INDEX idx_tasks_status ON tasks(status);
|
||||||
|
|
||||||
|
CREATE TABLE task_tags (
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE task_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
author_id TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
target TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_audit_at ON audit_log(at);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
device TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
last_active TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function bumpListeners() { listeners.forEach(fn => { try { fn(); } catch(e){} }); }
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
try {
|
||||||
|
const bytes = db.export();
|
||||||
|
const b64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
localStorage.setItem(LS_KEY, b64);
|
||||||
|
} catch(e) { console.warn('DashyDB persist failed', e); }
|
||||||
|
bumpListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromLS() {
|
||||||
|
const b64 = localStorage.getItem(LS_KEY);
|
||||||
|
if (!b64) return null;
|
||||||
|
const bin = atob(b64);
|
||||||
|
const arr = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed() {
|
||||||
|
db.exec('BEGIN');
|
||||||
|
USERS.forEach(u => {
|
||||||
|
const isAdmin = u.id === 'rod' || u.id === 'ayron';
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO users (id, name, role, hue, initials, email, phone, password_hash, account_type) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
[u.id, u.name, u.role, u.hue, u.initials, u.id + '@murchison-auto.co', '+64 27 555 0184',
|
||||||
|
'pbkdf2$' + Math.random().toString(36).slice(2, 18), isAdmin ? 'admin' : 'standard']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SEED_TASKS.forEach(t => {
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source, t.status || 'open', t.addedAt]
|
||||||
|
);
|
||||||
|
(t.tags || []).forEach(tag => {
|
||||||
|
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
SEED_AUDIT.forEach(a => {
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
|
||||||
|
[a.id, a.at, a.actor, a.action, a.summary, a.target]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// sample notes
|
||||||
|
db.run("INSERT INTO task_notes (task_id, author_id, body, created_at) VALUES ('t11','lani','Customer wants the work done before Mother''s Day weekend.','2026-05-08T09:14:00')");
|
||||||
|
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','MacBook · Chrome','Christchurch, NZ','2026-05-08T10:30:00')");
|
||||||
|
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','iPhone · Safari','Christchurch, NZ','2026-05-08T08:15:00')");
|
||||||
|
db.exec('COMMIT');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
SQL = await window.initSqlJs({
|
||||||
|
locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = loadFromLS();
|
||||||
|
if (existing) {
|
||||||
|
db = new SQL.Database(existing);
|
||||||
|
// migration: add account_type if missing
|
||||||
|
try {
|
||||||
|
const cols = query("PRAGMA table_info(users)").map(c => c.name);
|
||||||
|
if (!cols.includes('account_type')) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN account_type TEXT NOT NULL DEFAULT 'standard'");
|
||||||
|
db.run("UPDATE users SET account_type = 'admin' WHERE id IN ('rod','ayron')");
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('migration failed', e); }
|
||||||
|
} else {
|
||||||
|
db = new SQL.Database();
|
||||||
|
db.exec(SCHEMA);
|
||||||
|
seed();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
ready = true;
|
||||||
|
readyWaiters.splice(0).forEach(fn => fn());
|
||||||
|
bumpListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function whenReady() {
|
||||||
|
if (ready) return Promise.resolve();
|
||||||
|
return new Promise(r => readyWaiters.push(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- query helpers ----------------------------------------------------
|
||||||
|
function rowsFrom(stmt) {
|
||||||
|
const out = [];
|
||||||
|
while (stmt.step()) out.push(stmt.getAsObject());
|
||||||
|
stmt.free();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(sql, params = []) {
|
||||||
|
if (!db) return [];
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
if (params.length) stmt.bind(params);
|
||||||
|
return rowsFrom(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(sql, params = []) {
|
||||||
|
if (!db) return;
|
||||||
|
db.run(sql, params);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- domain ops -------------------------------------------------------
|
||||||
|
function listTasks() {
|
||||||
|
const rows = query(`
|
||||||
|
SELECT t.*, GROUP_CONCAT(tt.tag, '|') AS tags_csv
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.added_at DESC
|
||||||
|
`);
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id, title: r.title, description: r.description,
|
||||||
|
assignee: r.assignee_id, addedBy: r.added_by,
|
||||||
|
priority: r.priority, source: r.source, status: r.status,
|
||||||
|
addedAt: r.added_at,
|
||||||
|
tags: r.tags_csv ? r.tags_csv.split('|') : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAudit() {
|
||||||
|
return query('SELECT id, at, actor, action, summary, target FROM audit_log ORDER BY at DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listUsers() {
|
||||||
|
return query('SELECT * FROM users ORDER BY rowid ASC');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTask(taskId, toUserId, by) {
|
||||||
|
exec('UPDATE tasks SET assignee_id = ? WHERE id = ?', [toUserId, taskId]);
|
||||||
|
addAudit({
|
||||||
|
actor: by, action: 'task_moved',
|
||||||
|
summary: 'Moved task to ' + (USERS.find(u=>u.id===toUserId)||{name:toUserId}).name,
|
||||||
|
target: taskId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPriority(taskId, p) {
|
||||||
|
exec('UPDATE tasks SET priority = ? WHERE id = ?', [p, taskId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask(t) {
|
||||||
|
exec(
|
||||||
|
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source || 'manual', t.status || 'open', t.addedAt]
|
||||||
|
);
|
||||||
|
(t.tags || []).forEach(tag => {
|
||||||
|
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAudit(row) {
|
||||||
|
const id = 'a_' + Date.now() + '_' + Math.random().toString(36).slice(2,5);
|
||||||
|
exec(
|
||||||
|
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
|
||||||
|
[id, row.at || new Date().toISOString(), row.actor, row.action, row.summary, row.target || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUser(userId, edits) {
|
||||||
|
const fields = []; const vals = [];
|
||||||
|
if (edits.name !== undefined) { fields.push('name = ?'); vals.push(edits.name); }
|
||||||
|
if (edits.role !== undefined) { fields.push('role = ?'); vals.push(edits.role); }
|
||||||
|
if (edits.photo !== undefined) { fields.push('photo = ?'); vals.push(edits.photo); }
|
||||||
|
if (edits.account_type !== undefined) { fields.push('account_type = ?'); vals.push(edits.account_type); }
|
||||||
|
if (!fields.length) return;
|
||||||
|
vals.push(userId);
|
||||||
|
exec('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUser(u) {
|
||||||
|
const id = u.id || (u.name.toLowerCase().replace(/[^a-z]/g,'').slice(0,8) + '_' + Math.random().toString(36).slice(2,5));
|
||||||
|
const initials = (u.name.split(' ').map(s=>s[0]).join('') || 'U').slice(0,2).toUpperCase();
|
||||||
|
const hue = u.hue || Math.floor(Math.random() * 360);
|
||||||
|
exec(
|
||||||
|
'INSERT INTO users (id, name, role, hue, initials, email, phone, account_type, password_hash) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
[id, u.name, u.role || 'Team member', hue, initials,
|
||||||
|
u.email || (id + '@murchison-auto.co'), u.phone || '',
|
||||||
|
u.account_type || 'standard', 'pbkdf2$' + Math.random().toString(36).slice(2,18)]
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUser(userId) {
|
||||||
|
// reassign any tasks to ROD before delete
|
||||||
|
exec("UPDATE tasks SET assignee_id = 'rod' WHERE assignee_id = ?", [userId]);
|
||||||
|
exec('DELETE FROM users WHERE id = ?', [userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableNames() {
|
||||||
|
return query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map(r => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableInfo(name) {
|
||||||
|
return query(`PRAGMA table_info(${name})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowCount(name) {
|
||||||
|
const r = query(`SELECT COUNT(*) AS c FROM ${name}`);
|
||||||
|
return r[0] ? r[0].c : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFile() {
|
||||||
|
const bytes = db.export();
|
||||||
|
return new Blob([bytes], { type: 'application/x-sqlite3' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
localStorage.removeItem(LS_KEY);
|
||||||
|
db = new SQL.Database();
|
||||||
|
db.exec(SCHEMA);
|
||||||
|
seed();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawExec(sql) {
|
||||||
|
// returns array of {columns, values} for SELECTs, or empty for writes
|
||||||
|
const res = db.exec(sql);
|
||||||
|
persist();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
||||||
|
|
||||||
|
return {
|
||||||
|
init, whenReady, query, exec, rawExec,
|
||||||
|
listTasks, listAudit, listUsers,
|
||||||
|
createTask, moveTask, setPriority, updateUser, createUser, deleteUser, addAudit,
|
||||||
|
tableNames, tableInfo, rowCount, exportFile, reset, subscribe,
|
||||||
|
get isReady() { return ready; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
+1057
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"created": "2026-05-08T02:04:31.986Z",
|
||||||
|
"modified": "2026-05-08T02:04:42.961Z",
|
||||||
|
"objects": []
|
||||||
|
}
|
||||||
+1070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,568 @@
|
|||||||
|
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakColor label="Palette" value={t.palette}
|
||||||
|
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||||
|
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||||
|
// onChange={(v) => setTweak('palette', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
|
||||||
|
.twk-chips{display:flex;gap:6px}
|
||||||
|
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||||
|
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||||
|
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||||
|
.twk-chip:hover{transform:translateY(-1px);
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||||
|
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||||
|
0 2px 6px rgba(0,0,0,.15)}
|
||||||
|
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||||
|
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i:first-child{box-shadow:none}
|
||||||
|
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||||
|
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||||
|
// can react — the parent message only reaches the host, not peers.
|
||||||
|
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
|
||||||
|
// toggle drives the deck's per-viewer _railVisible via window message;
|
||||||
|
// state is mirrored from the same localStorage key the deck reads so
|
||||||
|
// the control reflects reality across reloads. The mechanism is the
|
||||||
|
// message — authors who want custom placement can post it directly
|
||||||
|
// and pass noDeckControls to suppress this one.
|
||||||
|
const hasDeckStage = React.useMemo(
|
||||||
|
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
// Hide the toggle until the host has actually enabled the rail (the
|
||||||
|
// __omelette_rail_enabled window message, posted only when the
|
||||||
|
// omelette_deck_rail_enabled flag is on for this user). The initial read
|
||||||
|
// covers TweaksPanel mounting after the message already arrived; the
|
||||||
|
// listener covers the common case of mounting first.
|
||||||
|
const [railEnabled, setRailEnabled] = React.useState(
|
||||||
|
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
|
||||||
|
);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasDeckStage || railEnabled) return undefined;
|
||||||
|
const onMsg = (e) => {
|
||||||
|
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, [hasDeckStage, railEnabled]);
|
||||||
|
const [railVisible, setRailVisible] = React.useState(() => {
|
||||||
|
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
|
||||||
|
});
|
||||||
|
const toggleRail = (on) => {
|
||||||
|
setRailVisible(on);
|
||||||
|
window.postMessage({ type: '__deck_rail_visible', on }, '*');
|
||||||
|
};
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-noncommentable=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">
|
||||||
|
{children}
|
||||||
|
{hasDeckStage && railEnabled && !noDeckControls && (
|
||||||
|
<TweakSection label="Deck">
|
||||||
|
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
|
||||||
|
</TweakSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||||
|
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||||
|
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||||
|
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||||
|
// back to a dropdown rather than wrap.
|
||||||
|
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||||
|
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||||
|
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||||
|
if (!fitsAsSegments) {
|
||||||
|
// <select> emits strings — map back to the original option value so the
|
||||||
|
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||||
|
const resolve = (s) => {
|
||||||
|
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||||
|
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||||
|
};
|
||||||
|
return <TweakSelect label={label} value={value} options={options}
|
||||||
|
onChange={(s) => onChange(resolve(s))} />;
|
||||||
|
}
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||||
|
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||||
|
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||||
|
function __twkIsLight(hex) {
|
||||||
|
const h = String(hex).replace('#', '');
|
||||||
|
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||||
|
const n = parseInt(x.slice(0, 6), 16);
|
||||||
|
if (Number.isNaN(n)) return true;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
return r * 299 + g * 587 + b * 114 > 148000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __TwkCheck = ({ light }) => (
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TweakColor — curated color/palette picker. Each option is either a single
|
||||||
|
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||||
|
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||||
|
// rest stacked in a sharp column on the right. onChange emits the
|
||||||
|
// option in the shape it was passed (string stays string, array stays array).
|
||||||
|
// Without options it falls back to the native color input for back-compat.
|
||||||
|
function TweakColor({ label, value, options, onChange }) {
|
||||||
|
if (!options || !options.length) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||||
|
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||||
|
// which returns the primitive undefined (no .toLowerCase).
|
||||||
|
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||||
|
const cur = key(value);
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div className="twk-chips" role="radiogroup">
|
||||||
|
{options.map((o, i) => {
|
||||||
|
const colors = Array.isArray(o) ? o : [o];
|
||||||
|
const [hero, ...rest] = colors;
|
||||||
|
const sup = rest.slice(0, 4);
|
||||||
|
const on = key(o) === cur;
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" className="twk-chip" role="radio"
|
||||||
|
aria-checked={on} data-on={on ? '1' : '0'}
|
||||||
|
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||||
|
style={{ background: hero }}
|
||||||
|
onClick={() => onChange(o)}>
|
||||||
|
{sup.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user