Commit ·
a73045d
1
Parent(s): b8b9460
Initial deploy
Browse files- .dockerignore +7 -0
- Dockerfile +46 -0
- README.md +18 -4
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/csv_utils.cpython-313.pyc +0 -0
- __pycache__/models.cpython-313.pyc +0 -0
- annotation.db +0 -0
- app.js +528 -0
- app.py +472 -0
- csv_data/ESConv-100samples-Annotated-gpt-4o-mini.csv +0 -0
- csv_utils.py +52 -0
- index.html +175 -0
- models.py +102 -0
- requirements.txt +4 -0
- static/admin.css +516 -0
- static/admin.js +190 -0
- static/app.js +675 -0
- static/style.css +686 -0
- style.css +685 -0
- templates/admin.html +118 -0
- templates/annotator.html +101 -0
- templates/login.html +97 -0
- templates/review.html +168 -0
.dockerignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
annotation.db
|
| 5 |
+
.git/
|
| 6 |
+
.gitignore
|
| 7 |
+
*.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# ── System deps ──
|
| 4 |
+
RUN apt-get update && \
|
| 5 |
+
apt-get install -y --no-install-recommends gcc libffi-dev && \
|
| 6 |
+
rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# ── Create non-root user (HF Spaces requirement) ──
|
| 9 |
+
RUN useradd -m -u 1000 user
|
| 10 |
+
ENV HOME=/home/user \
|
| 11 |
+
PATH=/home/user/.local/bin:$PATH
|
| 12 |
+
|
| 13 |
+
# ── App directory ──
|
| 14 |
+
WORKDIR /home/user/app
|
| 15 |
+
|
| 16 |
+
# ── Install Python deps ──
|
| 17 |
+
COPY --chown=user requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 19 |
+
pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# ── Copy application code ──
|
| 22 |
+
COPY --chown=user app.py models.py csv_utils.py ./
|
| 23 |
+
COPY --chown=user templates/ ./templates/
|
| 24 |
+
COPY --chown=user static/ ./static/
|
| 25 |
+
|
| 26 |
+
# ── Create data directories (for DB + CSV files) ──
|
| 27 |
+
RUN mkdir -p /home/user/app/csv_data && \
|
| 28 |
+
chown -R user:user /home/user/app
|
| 29 |
+
|
| 30 |
+
# ── Copy CSV data into container ──
|
| 31 |
+
# Place your .csv files in a csv_data/ folder next to the Dockerfile
|
| 32 |
+
COPY --chown=user csv_data/ ./csv_data/
|
| 33 |
+
|
| 34 |
+
# ── Environment variables ──
|
| 35 |
+
ENV CSV_FOLDER=/home/user/app/csv_data \
|
| 36 |
+
DB_PATH=/home/user/app/annotation.db \
|
| 37 |
+
SECRET_KEY=esconv-hf-spaces-secret-key-2024
|
| 38 |
+
|
| 39 |
+
# ── Switch to non-root user ──
|
| 40 |
+
USER user
|
| 41 |
+
|
| 42 |
+
# ── HF Spaces uses port 7860 ──
|
| 43 |
+
EXPOSE 7860
|
| 44 |
+
|
| 45 |
+
# ── Start with gunicorn ──
|
| 46 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "--timeout", "120", "app:app"]
|
README.md
CHANGED
|
@@ -1,10 +1,24 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ESConv Annotation Tool
|
| 3 |
+
emoji: 📝
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# ESConv Annotation Management System
|
| 12 |
+
|
| 13 |
+
A web-based annotation review tool for the ESConv dataset with cognitive distortion, emotion, and empathy labeling.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
- 🔐 Admin & Annotator role-based access
|
| 17 |
+
- 📊 Interactive conversation view with evidence highlighting
|
| 18 |
+
- ✏️ Edit mode for annotations (emotion, distortions, empathy)
|
| 19 |
+
- 📌 Text selection for evidence quotes
|
| 20 |
+
- 📥 Export to JSON/CSV
|
| 21 |
+
- 📈 Progress tracking dashboard
|
| 22 |
+
|
| 23 |
+
## Default Login
|
| 24 |
+
- **Admin**: `admin` / `admin123`
|
__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (22.3 kB). View file
|
|
|
__pycache__/csv_utils.cpython-313.pyc
ADDED
|
Binary file (2.76 kB). View file
|
|
|
__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (7.33 kB). View file
|
|
|
annotation.db
ADDED
|
Binary file (57.3 kB). View file
|
|
|
app.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ===== Global State =====
|
| 2 |
+
let allData = [];
|
| 3 |
+
let filteredData = [];
|
| 4 |
+
let currentIndex = 0;
|
| 5 |
+
let activeHighlights = new Set();
|
| 6 |
+
|
| 7 |
+
// Color palettes
|
| 8 |
+
const DIST_COLORS = ['#fb923c', '#f87171', '#fbbf24', '#a78bfa', '#34d399'];
|
| 9 |
+
const EMP_COLORS = ['#60a5fa','#a78bfa','#34d399','#fbbf24','#fb923c','#f87171','#818cf8','#2dd4bf','#e879f9'];
|
| 10 |
+
|
| 11 |
+
// ===== CSV Parser =====
|
| 12 |
+
function parseCSV(text) {
|
| 13 |
+
const rows = [];
|
| 14 |
+
let i = 0;
|
| 15 |
+
const len = text.length;
|
| 16 |
+
|
| 17 |
+
function parseField() {
|
| 18 |
+
if (text[i] === '"') {
|
| 19 |
+
i++; // skip opening quote
|
| 20 |
+
let field = '';
|
| 21 |
+
while (i < len) {
|
| 22 |
+
if (text[i] === '"' && text[i + 1] === '"') {
|
| 23 |
+
field += '"';
|
| 24 |
+
i += 2;
|
| 25 |
+
} else if (text[i] === '"') {
|
| 26 |
+
i++; // skip closing quote
|
| 27 |
+
break;
|
| 28 |
+
} else {
|
| 29 |
+
field += text[i++];
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
return field;
|
| 33 |
+
} else {
|
| 34 |
+
let start = i;
|
| 35 |
+
while (i < len && text[i] !== ',' && text[i] !== '\n' && text[i] !== '\r') i++;
|
| 36 |
+
return text.slice(start, i);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
while (i < len) {
|
| 41 |
+
const row = [];
|
| 42 |
+
while (i < len) {
|
| 43 |
+
row.push(parseField());
|
| 44 |
+
if (i < len && text[i] === ',') { i++; continue; }
|
| 45 |
+
if (i < len && (text[i] === '\n' || text[i] === '\r')) {
|
| 46 |
+
if (text[i] === '\r' && text[i + 1] === '\n') i++;
|
| 47 |
+
i++;
|
| 48 |
+
break;
|
| 49 |
+
}
|
| 50 |
+
if (i >= len) break;
|
| 51 |
+
}
|
| 52 |
+
if (row.length > 1 || (row.length === 1 && row[0] !== '')) {
|
| 53 |
+
rows.push(row);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
return rows;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function csvToObjects(rows) {
|
| 60 |
+
if (rows.length < 2) return [];
|
| 61 |
+
const headers = rows[0];
|
| 62 |
+
return rows.slice(1).map(row => {
|
| 63 |
+
const obj = {};
|
| 64 |
+
headers.forEach((h, idx) => { obj[h.trim()] = row[idx] || ''; });
|
| 65 |
+
return obj;
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ===== JSON Parse helpers =====
|
| 70 |
+
function safeParseJSON(str) {
|
| 71 |
+
if (!str) return null;
|
| 72 |
+
try {
|
| 73 |
+
// The CSV uses "" for escaped quotes inside JSON strings
|
| 74 |
+
// They've already been unescaped by our parser to single "
|
| 75 |
+
return JSON.parse(str);
|
| 76 |
+
} catch (e) {
|
| 77 |
+
return null;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ===== Parse the annotation columns =====
|
| 82 |
+
function parseAnnotations(row) {
|
| 83 |
+
const emotion = safeParseJSON(row['Emotion']);
|
| 84 |
+
const distortions = safeParseJSON(row['Distortions']);
|
| 85 |
+
const empathy = safeParseJSON(row['Empathy_Level']);
|
| 86 |
+
return { emotion, distortions, empathy };
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// ===== Parse conversation_cut =====
|
| 90 |
+
function parseConversation(convStr) {
|
| 91 |
+
if (!convStr) return [];
|
| 92 |
+
const lines = convStr.split('\n');
|
| 93 |
+
const messages = [];
|
| 94 |
+
lines.forEach(line => {
|
| 95 |
+
const trimmed = line.trim();
|
| 96 |
+
if (!trimmed) return;
|
| 97 |
+
if (trimmed.startsWith('speaker:') || trimmed.startsWith('listener:')) {
|
| 98 |
+
const isListener = trimmed.startsWith('listener:');
|
| 99 |
+
const content = trimmed.replace(/^(speaker|listener):\s*/, '');
|
| 100 |
+
if (content) {
|
| 101 |
+
messages.push({ role: isListener ? 'listener' : 'speaker', text: content });
|
| 102 |
+
}
|
| 103 |
+
} else if (messages.length > 0) {
|
| 104 |
+
// continuation line
|
| 105 |
+
messages[messages.length - 1].text += ' ' + trimmed;
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
return messages;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ===== Highlight text in conversation =====
|
| 112 |
+
function highlightTextInMessages(messages, quotes, cssClass) {
|
| 113 |
+
// returns messages array with highlight markers
|
| 114 |
+
return messages.map(msg => {
|
| 115 |
+
let text = msg.text;
|
| 116 |
+
quotes.forEach(({ quote, cls }) => {
|
| 117 |
+
if (!quote || quote === 'No evidence present') return;
|
| 118 |
+
// clean the quote from listener:/speaker: prefix
|
| 119 |
+
let q = quote.replace(/^(listener|speaker):\s*/i, '').trim();
|
| 120 |
+
if (!q) return;
|
| 121 |
+
const idx = text.indexOf(q);
|
| 122 |
+
if (idx !== -1) {
|
| 123 |
+
text = text.slice(0, idx)
|
| 124 |
+
+ `<mark class="evidence-hl ${cls}" data-quote="${escapeAttr(q)}">`
|
| 125 |
+
+ text.slice(idx, idx + q.length)
|
| 126 |
+
+ '</mark>'
|
| 127 |
+
+ text.slice(idx + q.length);
|
| 128 |
+
}
|
| 129 |
+
});
|
| 130 |
+
return { ...msg, htmlText: text };
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function escapeAttr(str) {
|
| 135 |
+
return str.replace(/"/g, '"').replace(/'/g, ''');
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function escapeHtml(str) {
|
| 139 |
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// ===== Emotion color class =====
|
| 143 |
+
function emotionClass(emotion) {
|
| 144 |
+
if (!emotion) return 'emotion-default';
|
| 145 |
+
const e = emotion.toLowerCase();
|
| 146 |
+
if (e.includes('fear')) return 'emotion-fear';
|
| 147 |
+
if (e.includes('sad')) return 'emotion-sadness';
|
| 148 |
+
if (e.includes('anger') || e.includes('angry')) return 'emotion-anger';
|
| 149 |
+
if (e.includes('joy') || e.includes('happy')) return 'emotion-joy';
|
| 150 |
+
if (e.includes('anxi')) return 'emotion-anxiety';
|
| 151 |
+
if (e.includes('shame')) return 'emotion-shame';
|
| 152 |
+
if (e.includes('depress')) return 'emotion-depression';
|
| 153 |
+
return 'emotion-default';
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// ===== Score color =====
|
| 157 |
+
function scoreColor(score, max = 7) {
|
| 158 |
+
const ratio = score / max;
|
| 159 |
+
if (ratio >= 0.7) return '#34d399';
|
| 160 |
+
if (ratio >= 0.4) return '#fbbf24';
|
| 161 |
+
return '#f87171';
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// ===== Render sample list =====
|
| 165 |
+
function renderSampleList() {
|
| 166 |
+
const list = document.getElementById('sampleList');
|
| 167 |
+
list.innerHTML = '';
|
| 168 |
+
filteredData.forEach((row, i) => {
|
| 169 |
+
const { emotion } = parseAnnotations(row);
|
| 170 |
+
const eName = emotion?.primary_emotion || row['emotion_type'] || '—';
|
| 171 |
+
const problem = row['problem_type'] || '';
|
| 172 |
+
|
| 173 |
+
const item = document.createElement('div');
|
| 174 |
+
item.className = 'sample-item' + (i === currentIndex ? ' active' : '');
|
| 175 |
+
item.dataset.idx = i;
|
| 176 |
+
item.innerHTML = `
|
| 177 |
+
<div class="sample-item-idx">#${i + 1}</div>
|
| 178 |
+
<div class="sample-item-emotion" style="color:var(--emotion-color)">${eName}</div>
|
| 179 |
+
<div class="sample-item-problem">${problem}</div>
|
| 180 |
+
`;
|
| 181 |
+
item.addEventListener('click', () => {
|
| 182 |
+
currentIndex = i;
|
| 183 |
+
renderAll();
|
| 184 |
+
});
|
| 185 |
+
list.appendChild(item);
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// ===== Render conversation =====
|
| 190 |
+
function renderConversation(row) {
|
| 191 |
+
const { distortions, empathy } = parseAnnotations(row);
|
| 192 |
+
const rawMessages = parseConversation(row['conversation_cut']);
|
| 193 |
+
|
| 194 |
+
// Build highlight quotes list
|
| 195 |
+
const allQuotes = [];
|
| 196 |
+
// Distortion quotes
|
| 197 |
+
const dists = distortions?.distortions || [];
|
| 198 |
+
dists.forEach((d, idx) => {
|
| 199 |
+
if (d.evidence_quote && d.evidence_quote !== 'No evidence present') {
|
| 200 |
+
allQuotes.push({ quote: d.evidence_quote, cls: `dist-hl-${idx % 3}`, type: 'distortion', idx });
|
| 201 |
+
}
|
| 202 |
+
});
|
| 203 |
+
// Empathy quotes
|
| 204 |
+
const dims = empathy?.empathy_dimensions || [];
|
| 205 |
+
dims.forEach((dim, idx) => {
|
| 206 |
+
if (dim.evidence_quote && dim.evidence_quote !== 'No evidence present') {
|
| 207 |
+
allQuotes.push({ quote: dim.evidence_quote, cls: `emp-hl-${idx % 9}`, type: 'empathy', idx });
|
| 208 |
+
}
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// Apply highlights
|
| 212 |
+
const highlighted = highlightTextInMessages(rawMessages, allQuotes, '');
|
| 213 |
+
|
| 214 |
+
const view = document.getElementById('conversationView');
|
| 215 |
+
view.innerHTML = highlighted.map(msg => {
|
| 216 |
+
const htmlContent = msg.htmlText || escapeHtml(msg.text);
|
| 217 |
+
return `
|
| 218 |
+
<div class="message-bubble ${msg.role}">
|
| 219 |
+
<div class="message-label">${msg.role === 'listener' ? 'Listener' : 'Speaker'}</div>
|
| 220 |
+
<div class="message-text">${htmlContent}</div>
|
| 221 |
+
</div>`;
|
| 222 |
+
}).join('');
|
| 223 |
+
|
| 224 |
+
// Attach tooltip events to marks
|
| 225 |
+
view.querySelectorAll('mark.evidence-hl').forEach(mark => {
|
| 226 |
+
mark.addEventListener('mouseenter', (e) => showMarkTooltip(e, mark, allQuotes));
|
| 227 |
+
mark.addEventListener('mouseleave', hideTooltip);
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// ===== Render conv header =====
|
| 232 |
+
function renderConvHeader(row) {
|
| 233 |
+
const { emotion } = parseAnnotations(row);
|
| 234 |
+
const eName = emotion?.primary_emotion || row['emotion_type'] || '—';
|
| 235 |
+
const eIntensity = emotion?.intensity || '';
|
| 236 |
+
const problem = row['problem_type'] || '';
|
| 237 |
+
const expType = row['experience_type'] || '';
|
| 238 |
+
|
| 239 |
+
const intClass = eIntensity.toLowerCase().includes('high') ? 'intensity-high'
|
| 240 |
+
: eIntensity.toLowerCase().includes('medium') ? 'intensity-medium' : 'intensity-low';
|
| 241 |
+
|
| 242 |
+
document.getElementById('convMeta').innerHTML = `
|
| 243 |
+
<span class="meta-badge ${emotionClass(eName)}">${eName}</span>
|
| 244 |
+
${eIntensity ? `<span class="intensity-badge ${intClass}">${eIntensity}</span>` : ''}
|
| 245 |
+
${problem ? `<span class="problem-badge">${problem}</span>` : ''}
|
| 246 |
+
${expType ? `<span class="experience-badge">${expType}</span>` : ''}
|
| 247 |
+
`;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// ===== Render Emotion Card =====
|
| 251 |
+
function renderEmotion(row) {
|
| 252 |
+
const { emotion } = parseAnnotations(row);
|
| 253 |
+
const el = document.getElementById('emotionContent');
|
| 254 |
+
if (!emotion) { el.innerHTML = '<p style="color:var(--text-muted);font-size:.8rem">No emotion annotation.</p>'; return; }
|
| 255 |
+
|
| 256 |
+
const intClass = (emotion.intensity || '').toLowerCase().includes('high') ? 'intensity-high'
|
| 257 |
+
: (emotion.intensity || '').toLowerCase().includes('medium') ? 'intensity-medium' : 'intensity-low';
|
| 258 |
+
|
| 259 |
+
el.innerHTML = `
|
| 260 |
+
<div class="emotion-primary">
|
| 261 |
+
<span class="emotion-badge ${emotionClass(emotion.primary_emotion)}">${emotion.primary_emotion || '—'}</span>
|
| 262 |
+
<span class="intensity-badge ${intClass}">${emotion.intensity || '—'}</span>
|
| 263 |
+
</div>
|
| 264 |
+
${emotion.specific_emotion_term ? `<p class="specific-term">Specific term: <span>${emotion.specific_emotion_term}</span></p>` : ''}
|
| 265 |
+
${emotion.reasoning ? `<p class="emotion-reasoning">${emotion.reasoning}</p>` : ''}
|
| 266 |
+
`;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/// ===== Scroll to evidence in conversation =====
|
| 270 |
+
function scrollToEvidence(cssClassPrefix, idx) {
|
| 271 |
+
const targetCls = `${cssClassPrefix}-${idx}`;
|
| 272 |
+
const mark = document.querySelector(`.conversation-view mark.${targetCls}`);
|
| 273 |
+
if (!mark) return;
|
| 274 |
+
|
| 275 |
+
// Remove previous pulse from all marks
|
| 276 |
+
document.querySelectorAll('mark.evidence-pulse').forEach(m => m.classList.remove('evidence-pulse'));
|
| 277 |
+
|
| 278 |
+
// Scroll into view
|
| 279 |
+
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 280 |
+
|
| 281 |
+
// Add pulse animation
|
| 282 |
+
mark.classList.add('evidence-pulse');
|
| 283 |
+
setTimeout(() => mark.classList.remove('evidence-pulse'), 2000);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// ===== Render Distortions Card =====
|
| 287 |
+
function renderDistortions(row) {
|
| 288 |
+
const { distortions } = parseAnnotations(row);
|
| 289 |
+
const el = document.getElementById('distortionsContent');
|
| 290 |
+
const dists = distortions?.distortions || [];
|
| 291 |
+
|
| 292 |
+
if (dists.length === 0) {
|
| 293 |
+
el.innerHTML = `
|
| 294 |
+
<div class="no-distortions">
|
| 295 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 296 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 297 |
+
</svg>
|
| 298 |
+
<span>No cognitive distortions detected</span>
|
| 299 |
+
</div>`;
|
| 300 |
+
return;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
el.innerHTML = dists.map((d, idx) => {
|
| 304 |
+
const color = DIST_COLORS[idx % DIST_COLORS.length];
|
| 305 |
+
const quoteClean = (d.evidence_quote || '').replace(/^(listener|speaker):\s*/i, '');
|
| 306 |
+
return `
|
| 307 |
+
<div class="distortion-item" data-dist-idx="${idx}">
|
| 308 |
+
<div class="distortion-name">
|
| 309 |
+
<span class="dist-color-dot" style="background:${color}"></span>
|
| 310 |
+
${d.cognitive_distortion || '—'}
|
| 311 |
+
</div>
|
| 312 |
+
${quoteClean ? `<div class="distortion-quote" style="border-color:${color}">"${quoteClean}"</div>` : ''}
|
| 313 |
+
${d.reasoning ? `<div class="distortion-reasoning">${d.reasoning}</div>` : ''}
|
| 314 |
+
</div>`;
|
| 315 |
+
}).join('');
|
| 316 |
+
|
| 317 |
+
// Click to scroll to evidence
|
| 318 |
+
el.querySelectorAll('.distortion-item').forEach(item => {
|
| 319 |
+
item.addEventListener('click', () => {
|
| 320 |
+
const idx = parseInt(item.dataset.distIdx);
|
| 321 |
+
scrollToEvidence('dist-hl', idx % 3);
|
| 322 |
+
});
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
// ===== Render Empathy Card =====
|
| 327 |
+
function renderEmpathy(row) {
|
| 328 |
+
const { empathy } = parseAnnotations(row);
|
| 329 |
+
const el = document.getElementById('empathyContent');
|
| 330 |
+
const scoreEl = document.getElementById('overallScore');
|
| 331 |
+
|
| 332 |
+
if (!empathy) { el.innerHTML = '<p style="color:var(--text-muted);font-size:.8rem">No empathy annotation.</p>'; scoreEl.textContent = ''; return; }
|
| 333 |
+
|
| 334 |
+
const dims = empathy.empathy_dimensions || [];
|
| 335 |
+
const overall = empathy.overall_score;
|
| 336 |
+
scoreEl.textContent = overall !== undefined ? `${overall} / ${dims.length * 7}` : '';
|
| 337 |
+
|
| 338 |
+
el.innerHTML = dims.map((dim, idx) => {
|
| 339 |
+
const color = EMP_COLORS[idx % EMP_COLORS.length];
|
| 340 |
+
const score = dim.score || 0;
|
| 341 |
+
const pct = Math.min(100, (score / 7) * 100);
|
| 342 |
+
const sColor = scoreColor(score);
|
| 343 |
+
const quoteClean = (dim.evidence_quote || '').replace(/^(listener|speaker):\s*/i, '');
|
| 344 |
+
return `
|
| 345 |
+
<div class="empathy-item" data-emp-idx="${idx}">
|
| 346 |
+
<div class="empathy-item-header">
|
| 347 |
+
<span class="emp-color-dot" style="background:${color}"></span>
|
| 348 |
+
<span class="empathy-name">${dim.dimension_name || '—'}</span>
|
| 349 |
+
<div class="score-bar-container">
|
| 350 |
+
<div class="score-bar">
|
| 351 |
+
<div class="score-bar-fill" style="width:${pct}%;background:${sColor}"></div>
|
| 352 |
+
</div>
|
| 353 |
+
<span class="score-value" style="color:${sColor}">${score}</span>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
${quoteClean && quoteClean !== 'No evidence present'
|
| 357 |
+
? `<div class="empathy-quote" style="border-color:${color}" title="${escapeAttr(quoteClean)}">"${quoteClean}"</div>`
|
| 358 |
+
: ''}
|
| 359 |
+
${dim.reasoning ? `<div class="empathy-reasoning">${dim.reasoning}</div>` : ''}
|
| 360 |
+
</div>`;
|
| 361 |
+
}).join('');
|
| 362 |
+
|
| 363 |
+
// Click to expand reasoning + scroll to evidence
|
| 364 |
+
el.querySelectorAll('.empathy-item').forEach(item => {
|
| 365 |
+
item.addEventListener('click', () => {
|
| 366 |
+
item.classList.toggle('active');
|
| 367 |
+
const idx = parseInt(item.dataset.empIdx);
|
| 368 |
+
scrollToEvidence('emp-hl', idx % 9);
|
| 369 |
+
});
|
| 370 |
+
});
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// ===== Tooltip =====
|
| 374 |
+
function showMarkTooltip(event, mark, allQuotes) {
|
| 375 |
+
const tooltip = document.getElementById('tooltip');
|
| 376 |
+
const content = document.getElementById('tooltipContent');
|
| 377 |
+
|
| 378 |
+
const markQuote = mark.dataset.quote || '';
|
| 379 |
+
// Find matching annotation
|
| 380 |
+
const cls = Array.from(mark.classList).find(c => c.startsWith('dist-hl-') || c.startsWith('emp-hl-'));
|
| 381 |
+
|
| 382 |
+
const { distortions, empathy } = parseAnnotations(filteredData[currentIndex]);
|
| 383 |
+
let html = '';
|
| 384 |
+
|
| 385 |
+
if (cls && cls.startsWith('dist-hl-')) {
|
| 386 |
+
const idx = parseInt(cls.split('-').pop());
|
| 387 |
+
const d = (distortions?.distortions || [])[idx];
|
| 388 |
+
if (d) {
|
| 389 |
+
const color = DIST_COLORS[idx % DIST_COLORS.length];
|
| 390 |
+
html = `
|
| 391 |
+
<div class="tooltip-title" style="color:${color}">⚠️ ${d.cognitive_distortion || 'Distortion'}</div>
|
| 392 |
+
<div class="tooltip-label">Type</div>
|
| 393 |
+
<div class="tooltip-text">Cognitive Distortion</div>
|
| 394 |
+
<div class="tooltip-label">Reasoning</div>
|
| 395 |
+
<div class="tooltip-text">${d.reasoning || '—'}</div>
|
| 396 |
+
`;
|
| 397 |
+
}
|
| 398 |
+
} else if (cls && cls.startsWith('emp-hl-')) {
|
| 399 |
+
const idx = parseInt(cls.split('-').pop());
|
| 400 |
+
const dim = (empathy?.empathy_dimensions || [])[idx];
|
| 401 |
+
if (dim) {
|
| 402 |
+
const color = EMP_COLORS[idx % EMP_COLORS.length];
|
| 403 |
+
const sColor = scoreColor(dim.score || 0);
|
| 404 |
+
html = `
|
| 405 |
+
<div class="tooltip-title" style="color:${color}">💡 ${dim.dimension_name || 'Empathy'}</div>
|
| 406 |
+
<div class="tooltip-label">Score</div>
|
| 407 |
+
<span class="tooltip-score" style="background:${sColor}22;color:${sColor};border:1px solid ${sColor}44">${dim.score} / 7</span>
|
| 408 |
+
<div class="tooltip-label">Reasoning</div>
|
| 409 |
+
<div class="tooltip-text">${dim.reasoning || '—'}</div>
|
| 410 |
+
`;
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
if (!html) return;
|
| 415 |
+
content.innerHTML = html;
|
| 416 |
+
tooltip.classList.add('visible');
|
| 417 |
+
|
| 418 |
+
// Position tooltip
|
| 419 |
+
const rect = mark.getBoundingClientRect();
|
| 420 |
+
const tRect = tooltip.getBoundingClientRect();
|
| 421 |
+
let top = rect.bottom + 8;
|
| 422 |
+
let left = rect.left;
|
| 423 |
+
if (left + 320 > window.innerWidth) left = window.innerWidth - 330;
|
| 424 |
+
if (top + 200 > window.innerHeight) top = rect.top - 200;
|
| 425 |
+
tooltip.style.top = top + 'px';
|
| 426 |
+
tooltip.style.left = left + 'px';
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
function hideTooltip() {
|
| 430 |
+
const tooltip = document.getElementById('tooltip');
|
| 431 |
+
tooltip.classList.remove('visible');
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// ===== Navigation =====
|
| 435 |
+
function updateNavButtons() {
|
| 436 |
+
document.getElementById('prevBtn').disabled = currentIndex <= 0;
|
| 437 |
+
document.getElementById('nextBtn').disabled = currentIndex >= filteredData.length - 1;
|
| 438 |
+
document.getElementById('currentIdx').textContent = currentIndex + 1;
|
| 439 |
+
document.getElementById('totalSamples').textContent = filteredData.length;
|
| 440 |
+
|
| 441 |
+
// Update sidebar active
|
| 442 |
+
document.querySelectorAll('.sample-item').forEach(item => {
|
| 443 |
+
item.classList.toggle('active', parseInt(item.dataset.idx) === currentIndex);
|
| 444 |
+
});
|
| 445 |
+
|
| 446 |
+
// Scroll active into view
|
| 447 |
+
const activeItem = document.querySelector('.sample-item.active');
|
| 448 |
+
if (activeItem) activeItem.scrollIntoView({ block: 'nearest' });
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
function renderAll() {
|
| 452 |
+
if (filteredData.length === 0) return;
|
| 453 |
+
const row = filteredData[currentIndex];
|
| 454 |
+
renderConvHeader(row);
|
| 455 |
+
renderConversation(row);
|
| 456 |
+
renderEmotion(row);
|
| 457 |
+
renderDistortions(row);
|
| 458 |
+
renderEmpathy(row);
|
| 459 |
+
updateNavButtons();
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// ===== Filter / Search =====
|
| 463 |
+
function applyFilter(query) {
|
| 464 |
+
const q = query.toLowerCase().trim();
|
| 465 |
+
filteredData = q
|
| 466 |
+
? allData.filter(row => {
|
| 467 |
+
const { emotion } = parseAnnotations(row);
|
| 468 |
+
const eName = (emotion?.primary_emotion || row['emotion_type'] || '').toLowerCase();
|
| 469 |
+
const prob = (row['problem_type'] || '').toLowerCase();
|
| 470 |
+
return eName.includes(q) || prob.includes(q);
|
| 471 |
+
})
|
| 472 |
+
: [...allData];
|
| 473 |
+
currentIndex = 0;
|
| 474 |
+
renderSampleList();
|
| 475 |
+
renderAll();
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
// ===== Load CSV =====
|
| 479 |
+
function loadCSV(text) {
|
| 480 |
+
const rows = parseCSV(text);
|
| 481 |
+
const data = csvToObjects(rows);
|
| 482 |
+
allData = data.filter(d => d['conversation_cut']);
|
| 483 |
+
filteredData = [...allData];
|
| 484 |
+
currentIndex = 0;
|
| 485 |
+
|
| 486 |
+
document.getElementById('welcomeScreen').style.display = 'none';
|
| 487 |
+
document.getElementById('mainLayout').style.display = 'grid';
|
| 488 |
+
document.getElementById('sampleCounter').style.display = 'flex';
|
| 489 |
+
document.getElementById('navArrows').style.display = 'flex';
|
| 490 |
+
document.getElementById('fileLabel').textContent = `${allData.length} samples`;
|
| 491 |
+
|
| 492 |
+
renderSampleList();
|
| 493 |
+
renderAll();
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
// ===== Event Listeners =====
|
| 497 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 498 |
+
const csvInput = document.getElementById('csvInput');
|
| 499 |
+
csvInput.addEventListener('change', (e) => {
|
| 500 |
+
const file = e.target.files[0];
|
| 501 |
+
if (!file) return;
|
| 502 |
+
const reader = new FileReader();
|
| 503 |
+
reader.onload = (ev) => loadCSV(ev.target.result);
|
| 504 |
+
reader.readAsText(file, 'UTF-8');
|
| 505 |
+
});
|
| 506 |
+
|
| 507 |
+
document.getElementById('prevBtn').addEventListener('click', () => {
|
| 508 |
+
if (currentIndex > 0) { currentIndex--; renderAll(); }
|
| 509 |
+
});
|
| 510 |
+
document.getElementById('nextBtn').addEventListener('click', () => {
|
| 511 |
+
if (currentIndex < filteredData.length - 1) { currentIndex++; renderAll(); }
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
document.getElementById('searchInput').addEventListener('input', (e) => {
|
| 515 |
+
applyFilter(e.target.value);
|
| 516 |
+
});
|
| 517 |
+
|
| 518 |
+
// Keyboard navigation
|
| 519 |
+
document.addEventListener('keydown', (e) => {
|
| 520 |
+
if (e.target.tagName === 'INPUT') return;
|
| 521 |
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
| 522 |
+
if (currentIndex > 0) { currentIndex--; renderAll(); }
|
| 523 |
+
}
|
| 524 |
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
| 525 |
+
if (currentIndex < filteredData.length - 1) { currentIndex++; renderAll(); }
|
| 526 |
+
}
|
| 527 |
+
});
|
| 528 |
+
});
|
app.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flask application for the ESConv Annotation Management System."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from functools import wraps
|
| 6 |
+
|
| 7 |
+
import bcrypt
|
| 8 |
+
from flask import (
|
| 9 |
+
Flask, jsonify, redirect, render_template, request, session, url_for
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
from models import db, User, CsvFile, Assignment, Review
|
| 13 |
+
from csv_utils import parse_csv_file, count_samples, scan_csv_folder
|
| 14 |
+
|
| 15 |
+
# ── Config ──────────────────────────────────────────────────────────────────
|
| 16 |
+
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
| 17 |
+
CSV_FOLDER = os.environ.get('CSV_FOLDER', os.path.join(os.path.dirname(BASE_DIR), 'csv_data'))
|
| 18 |
+
DB_PATH = os.environ.get('DB_PATH', os.path.join(BASE_DIR, 'annotation.db'))
|
| 19 |
+
|
| 20 |
+
app = Flask(__name__)
|
| 21 |
+
app.secret_key = os.environ.get('SECRET_KEY', 'esconv-annotation-secret-key-2024')
|
| 22 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
|
| 23 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 24 |
+
|
| 25 |
+
db.init_app(app)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Helpers ─────────────────────────────────────────────────────────────────
|
| 29 |
+
def hash_password(pw):
|
| 30 |
+
return bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def check_password(pw, hashed):
|
| 34 |
+
return bcrypt.checkpw(pw.encode('utf-8'), hashed.encode('utf-8'))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def login_required(f):
|
| 38 |
+
@wraps(f)
|
| 39 |
+
def wrapper(*args, **kwargs):
|
| 40 |
+
if 'user_id' not in session:
|
| 41 |
+
if request.path.startswith('/api/'):
|
| 42 |
+
return jsonify({'error': 'Not authenticated'}), 401
|
| 43 |
+
return redirect(url_for('login_page'))
|
| 44 |
+
return f(*args, **kwargs)
|
| 45 |
+
return wrapper
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def admin_required(f):
|
| 49 |
+
@wraps(f)
|
| 50 |
+
def wrapper(*args, **kwargs):
|
| 51 |
+
if 'user_id' not in session:
|
| 52 |
+
if request.path.startswith('/api/'):
|
| 53 |
+
return jsonify({'error': 'Not authenticated'}), 401
|
| 54 |
+
return redirect(url_for('login_page'))
|
| 55 |
+
if session.get('role') != 'admin':
|
| 56 |
+
if request.path.startswith('/api/'):
|
| 57 |
+
return jsonify({'error': 'Admin access required'}), 403
|
| 58 |
+
return redirect(url_for('login_page'))
|
| 59 |
+
return f(*args, **kwargs)
|
| 60 |
+
return wrapper
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def sync_csv_files():
|
| 64 |
+
"""Scan the CSV folder and sync with the database."""
|
| 65 |
+
os.makedirs(CSV_FOLDER, exist_ok=True)
|
| 66 |
+
disk_files = scan_csv_folder(CSV_FOLDER)
|
| 67 |
+
disk_names = {f['filename'] for f in disk_files}
|
| 68 |
+
|
| 69 |
+
# Add new files
|
| 70 |
+
for finfo in disk_files:
|
| 71 |
+
existing = CsvFile.query.filter_by(filename=finfo['filename']).first()
|
| 72 |
+
if not existing:
|
| 73 |
+
total = count_samples(finfo['filepath'])
|
| 74 |
+
new_file = CsvFile(
|
| 75 |
+
filename=finfo['filename'],
|
| 76 |
+
filepath=finfo['filepath'],
|
| 77 |
+
total_samples=total,
|
| 78 |
+
)
|
| 79 |
+
db.session.add(new_file)
|
| 80 |
+
|
| 81 |
+
# Remove DB entries for files no longer on disk
|
| 82 |
+
all_db_files = CsvFile.query.all()
|
| 83 |
+
for dbf in all_db_files:
|
| 84 |
+
if dbf.filename not in disk_names:
|
| 85 |
+
db.session.delete(dbf)
|
| 86 |
+
|
| 87 |
+
db.session.commit()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── Auth Routes ─────────────────────────────────────────────────────────────
|
| 91 |
+
@app.route('/login', methods=['GET'])
|
| 92 |
+
def login_page():
|
| 93 |
+
if 'user_id' in session:
|
| 94 |
+
return redirect(url_for('dashboard'))
|
| 95 |
+
return render_template('login.html')
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.route('/api/login', methods=['POST'])
|
| 99 |
+
def api_login():
|
| 100 |
+
data = request.get_json()
|
| 101 |
+
username = data.get('username', '').strip()
|
| 102 |
+
password = data.get('password', '')
|
| 103 |
+
|
| 104 |
+
user = User.query.filter_by(username=username).first()
|
| 105 |
+
if not user or not check_password(password, user.password_hash):
|
| 106 |
+
return jsonify({'error': 'Invalid username or password'}), 401
|
| 107 |
+
|
| 108 |
+
session['user_id'] = user.id
|
| 109 |
+
session['username'] = user.username
|
| 110 |
+
session['role'] = user.role
|
| 111 |
+
return jsonify({'ok': True, 'role': user.role, 'username': user.username})
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@app.route('/api/logout', methods=['POST'])
|
| 115 |
+
def api_logout():
|
| 116 |
+
session.clear()
|
| 117 |
+
return jsonify({'ok': True})
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@app.route('/api/me')
|
| 121 |
+
@login_required
|
| 122 |
+
def api_me():
|
| 123 |
+
return jsonify({
|
| 124 |
+
'user_id': session['user_id'],
|
| 125 |
+
'username': session['username'],
|
| 126 |
+
'role': session['role'],
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ── Page Routes ─────────────────────────────────────────────────────────────
|
| 131 |
+
@app.route('/')
|
| 132 |
+
@login_required
|
| 133 |
+
def dashboard():
|
| 134 |
+
if session.get('role') == 'admin':
|
| 135 |
+
return redirect(url_for('admin_page'))
|
| 136 |
+
return redirect(url_for('annotator_page'))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@app.route('/admin')
|
| 140 |
+
@admin_required
|
| 141 |
+
def admin_page():
|
| 142 |
+
return render_template('admin.html')
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.route('/annotator')
|
| 146 |
+
@login_required
|
| 147 |
+
def annotator_page():
|
| 148 |
+
return render_template('annotator.html')
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.route('/review/<int:assignment_id>')
|
| 152 |
+
@login_required
|
| 153 |
+
def review_page(assignment_id):
|
| 154 |
+
assignment = Assignment.query.get_or_404(assignment_id)
|
| 155 |
+
# Only the assigned user or admin can access
|
| 156 |
+
if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
|
| 157 |
+
return redirect(url_for('dashboard'))
|
| 158 |
+
return render_template('review.html')
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── Admin API: Users ────────────────────────────────────────────────────────
|
| 162 |
+
@app.route('/api/users', methods=['GET'])
|
| 163 |
+
@admin_required
|
| 164 |
+
def api_get_users():
|
| 165 |
+
users = User.query.order_by(User.created_at).all()
|
| 166 |
+
return jsonify([u.to_dict() for u in users])
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@app.route('/api/users', methods=['POST'])
|
| 170 |
+
@admin_required
|
| 171 |
+
def api_create_user():
|
| 172 |
+
data = request.get_json()
|
| 173 |
+
username = data.get('username', '').strip()
|
| 174 |
+
password = data.get('password', '')
|
| 175 |
+
role = data.get('role', 'annotator')
|
| 176 |
+
|
| 177 |
+
if not username or not password:
|
| 178 |
+
return jsonify({'error': 'Username and password required'}), 400
|
| 179 |
+
if len(password) < 4:
|
| 180 |
+
return jsonify({'error': 'Password must be at least 4 characters'}), 400
|
| 181 |
+
if role not in ('admin', 'annotator'):
|
| 182 |
+
return jsonify({'error': 'Invalid role'}), 400
|
| 183 |
+
if User.query.filter_by(username=username).first():
|
| 184 |
+
return jsonify({'error': 'Username already exists'}), 400
|
| 185 |
+
|
| 186 |
+
user = User(username=username, password_hash=hash_password(password), role=role)
|
| 187 |
+
db.session.add(user)
|
| 188 |
+
db.session.commit()
|
| 189 |
+
return jsonify(user.to_dict()), 201
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
|
| 193 |
+
@admin_required
|
| 194 |
+
def api_delete_user(user_id):
|
| 195 |
+
user = User.query.get_or_404(user_id)
|
| 196 |
+
if user.id == session['user_id']:
|
| 197 |
+
return jsonify({'error': 'Cannot delete yourself'}), 400
|
| 198 |
+
db.session.delete(user)
|
| 199 |
+
db.session.commit()
|
| 200 |
+
return jsonify({'ok': True})
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ── Admin API: Files ────────────────────────────────────────────────────────
|
| 204 |
+
@app.route('/api/files', methods=['GET'])
|
| 205 |
+
@admin_required
|
| 206 |
+
def api_get_files():
|
| 207 |
+
sync_csv_files()
|
| 208 |
+
files = CsvFile.query.order_by(CsvFile.filename).all()
|
| 209 |
+
result = []
|
| 210 |
+
for f in files:
|
| 211 |
+
d = f.to_dict()
|
| 212 |
+
# Count total reviews across all assignments for this file
|
| 213 |
+
total_reviews = (
|
| 214 |
+
db.session.query(Review)
|
| 215 |
+
.join(Assignment)
|
| 216 |
+
.filter(Assignment.file_id == f.id)
|
| 217 |
+
.count()
|
| 218 |
+
)
|
| 219 |
+
total_assignees = Assignment.query.filter_by(file_id=f.id).count()
|
| 220 |
+
d['total_reviews'] = total_reviews
|
| 221 |
+
d['total_assignees'] = total_assignees
|
| 222 |
+
result.append(d)
|
| 223 |
+
return jsonify(result)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.route('/api/files/<int:file_id>/data', methods=['GET'])
|
| 227 |
+
@login_required
|
| 228 |
+
def api_get_file_data(file_id):
|
| 229 |
+
csv_file = CsvFile.query.get_or_404(file_id)
|
| 230 |
+
rows = parse_csv_file(csv_file.filepath)
|
| 231 |
+
return jsonify({'filename': csv_file.filename, 'samples': rows})
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ── Admin API: Assignments ──────────────────────────────────────────────────
|
| 235 |
+
@app.route('/api/assignments', methods=['GET'])
|
| 236 |
+
@admin_required
|
| 237 |
+
def api_get_assignments():
|
| 238 |
+
assignments = Assignment.query.order_by(Assignment.assigned_at.desc()).all()
|
| 239 |
+
return jsonify([a.to_dict() for a in assignments])
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
@app.route('/api/assignments', methods=['POST'])
|
| 243 |
+
@admin_required
|
| 244 |
+
def api_create_assignment():
|
| 245 |
+
data = request.get_json()
|
| 246 |
+
user_id = data.get('user_id')
|
| 247 |
+
file_id = data.get('file_id')
|
| 248 |
+
|
| 249 |
+
if not user_id or not file_id:
|
| 250 |
+
return jsonify({'error': 'user_id and file_id required'}), 400
|
| 251 |
+
|
| 252 |
+
user = User.query.get(user_id)
|
| 253 |
+
csv_file = CsvFile.query.get(file_id)
|
| 254 |
+
if not user:
|
| 255 |
+
return jsonify({'error': 'User not found'}), 404
|
| 256 |
+
if not csv_file:
|
| 257 |
+
return jsonify({'error': 'File not found'}), 404
|
| 258 |
+
|
| 259 |
+
existing = Assignment.query.filter_by(user_id=user_id, file_id=file_id).first()
|
| 260 |
+
if existing:
|
| 261 |
+
return jsonify({'error': 'Assignment already exists'}), 400
|
| 262 |
+
|
| 263 |
+
assignment = Assignment(user_id=user_id, file_id=file_id)
|
| 264 |
+
db.session.add(assignment)
|
| 265 |
+
db.session.commit()
|
| 266 |
+
return jsonify(assignment.to_dict()), 201
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
@app.route('/api/assignments/<int:assignment_id>', methods=['DELETE'])
|
| 270 |
+
@admin_required
|
| 271 |
+
def api_delete_assignment(assignment_id):
|
| 272 |
+
assignment = Assignment.query.get_or_404(assignment_id)
|
| 273 |
+
db.session.delete(assignment)
|
| 274 |
+
db.session.commit()
|
| 275 |
+
return jsonify({'ok': True})
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ── Annotator API ───────────────────────────────────────────────────────────
|
| 279 |
+
@app.route('/api/my-assignments', methods=['GET'])
|
| 280 |
+
@login_required
|
| 281 |
+
def api_my_assignments():
|
| 282 |
+
assignments = Assignment.query.filter_by(user_id=session['user_id']).all()
|
| 283 |
+
return jsonify([a.to_dict() for a in assignments])
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@app.route('/api/assignments/<int:assignment_id>/reviews', methods=['GET'])
|
| 287 |
+
@login_required
|
| 288 |
+
def api_get_reviews(assignment_id):
|
| 289 |
+
assignment = Assignment.query.get_or_404(assignment_id)
|
| 290 |
+
if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
|
| 291 |
+
return jsonify({'error': 'Access denied'}), 403
|
| 292 |
+
reviews = Review.query.filter_by(assignment_id=assignment_id).all()
|
| 293 |
+
return jsonify({r.sample_index: r.to_dict() for r in reviews})
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@app.route('/api/reviews', methods=['POST'])
|
| 297 |
+
@login_required
|
| 298 |
+
def api_submit_review():
|
| 299 |
+
data = request.get_json()
|
| 300 |
+
assignment_id = data.get('assignment_id')
|
| 301 |
+
sample_index = data.get('sample_index')
|
| 302 |
+
status = data.get('status') # 'accept' or 'reject'
|
| 303 |
+
comment = data.get('comment', '')
|
| 304 |
+
|
| 305 |
+
if not assignment_id or sample_index is None or status not in ('accept', 'reject'):
|
| 306 |
+
return jsonify({'error': 'assignment_id, sample_index, and valid status required'}), 400
|
| 307 |
+
|
| 308 |
+
assignment = Assignment.query.get(assignment_id)
|
| 309 |
+
if not assignment:
|
| 310 |
+
return jsonify({'error': 'Assignment not found'}), 404
|
| 311 |
+
if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
|
| 312 |
+
return jsonify({'error': 'Access denied'}), 403
|
| 313 |
+
|
| 314 |
+
review = Review.query.filter_by(
|
| 315 |
+
assignment_id=assignment_id, sample_index=sample_index
|
| 316 |
+
).first()
|
| 317 |
+
|
| 318 |
+
if review:
|
| 319 |
+
review.status = status
|
| 320 |
+
review.comment = comment
|
| 321 |
+
review.reviewed_at = datetime.now(timezone.utc)
|
| 322 |
+
else:
|
| 323 |
+
review = Review(
|
| 324 |
+
assignment_id=assignment_id,
|
| 325 |
+
sample_index=sample_index,
|
| 326 |
+
status=status,
|
| 327 |
+
comment=comment,
|
| 328 |
+
)
|
| 329 |
+
db.session.add(review)
|
| 330 |
+
|
| 331 |
+
db.session.commit()
|
| 332 |
+
return jsonify(review.to_dict())
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@app.route('/api/reviews/annotations', methods=['PATCH'])
|
| 336 |
+
@login_required
|
| 337 |
+
def api_save_annotations():
|
| 338 |
+
"""Save annotator-edited annotations for a specific sample."""
|
| 339 |
+
import json as _json
|
| 340 |
+
data = request.get_json()
|
| 341 |
+
assignment_id = data.get('assignment_id')
|
| 342 |
+
sample_index = data.get('sample_index')
|
| 343 |
+
annotations = data.get('annotations') # dict: {emotion, distortions, empathy}
|
| 344 |
+
|
| 345 |
+
if not assignment_id or sample_index is None or annotations is None:
|
| 346 |
+
return jsonify({'error': 'assignment_id, sample_index, and annotations required'}), 400
|
| 347 |
+
|
| 348 |
+
assignment = Assignment.query.get(assignment_id)
|
| 349 |
+
if not assignment:
|
| 350 |
+
return jsonify({'error': 'Assignment not found'}), 404
|
| 351 |
+
if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
|
| 352 |
+
return jsonify({'error': 'Access denied'}), 403
|
| 353 |
+
|
| 354 |
+
# Upsert review — annotations can be saved without status yet (use 'pending' placeholder)
|
| 355 |
+
review = Review.query.filter_by(
|
| 356 |
+
assignment_id=assignment_id, sample_index=sample_index
|
| 357 |
+
).first()
|
| 358 |
+
|
| 359 |
+
if review:
|
| 360 |
+
review.annotations_edit = _json.dumps(annotations, ensure_ascii=False)
|
| 361 |
+
review.reviewed_at = datetime.now(timezone.utc)
|
| 362 |
+
else:
|
| 363 |
+
# Create a minimal review record to hold the annotation edits
|
| 364 |
+
review = Review(
|
| 365 |
+
assignment_id=assignment_id,
|
| 366 |
+
sample_index=sample_index,
|
| 367 |
+
status='reject', # default, annotator must explicitly submit verdict
|
| 368 |
+
comment='',
|
| 369 |
+
annotations_edit=_json.dumps(annotations, ensure_ascii=False),
|
| 370 |
+
)
|
| 371 |
+
db.session.add(review)
|
| 372 |
+
|
| 373 |
+
db.session.commit()
|
| 374 |
+
return jsonify(review.to_dict())
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# ── Export API ──────────────────────────────────────────────────────────────
|
| 379 |
+
@app.route('/api/assignments/<int:assignment_id>/export', methods=['GET'])
|
| 380 |
+
@login_required
|
| 381 |
+
def api_export(assignment_id):
|
| 382 |
+
"""Export reviewed data as JSON or CSV."""
|
| 383 |
+
import json as _json
|
| 384 |
+
import csv
|
| 385 |
+
import io
|
| 386 |
+
|
| 387 |
+
fmt = request.args.get('format', 'json') # 'json' or 'csv'
|
| 388 |
+
assignment = Assignment.query.get_or_404(assignment_id)
|
| 389 |
+
if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
|
| 390 |
+
return jsonify({'error': 'Access denied'}), 403
|
| 391 |
+
|
| 392 |
+
csv_file = assignment.csv_file
|
| 393 |
+
rows = parse_csv_file(csv_file.filepath)
|
| 394 |
+
reviews = {r.sample_index: r for r in Review.query.filter_by(assignment_id=assignment_id).all()}
|
| 395 |
+
|
| 396 |
+
export_rows = []
|
| 397 |
+
for i, row in enumerate(rows):
|
| 398 |
+
review = reviews.get(i)
|
| 399 |
+
# Get effective annotations (edited or original)
|
| 400 |
+
if review and review.annotations_edit:
|
| 401 |
+
anno = _json.loads(review.annotations_edit)
|
| 402 |
+
else:
|
| 403 |
+
anno = {
|
| 404 |
+
'emotion': _json.loads(row.get('Emotion', 'null')) if row.get('Emotion') else None,
|
| 405 |
+
'distortions': _json.loads(row.get('Distortions', 'null')) if row.get('Distortions') else None,
|
| 406 |
+
'empathy': _json.loads(row.get('Empathy_Level', 'null')) if row.get('Empathy_Level') else None,
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
export_rows.append({
|
| 410 |
+
'sample_index': i,
|
| 411 |
+
'conversation_cut': row.get('conversation_cut', ''),
|
| 412 |
+
'emotion_type': row.get('emotion_type', ''),
|
| 413 |
+
'problem_type': row.get('problem_type', ''),
|
| 414 |
+
'Emotion': anno.get('emotion'),
|
| 415 |
+
'Distortions': anno.get('distortions'),
|
| 416 |
+
'Empathy_Level': anno.get('empathy'),
|
| 417 |
+
'review_status': review.status if review else 'pending',
|
| 418 |
+
'review_comment': review.comment if review else '',
|
| 419 |
+
'is_edited': bool(review and review.annotations_edit),
|
| 420 |
+
})
|
| 421 |
+
|
| 422 |
+
if fmt == 'csv':
|
| 423 |
+
output = io.StringIO()
|
| 424 |
+
writer = csv.writer(output)
|
| 425 |
+
writer.writerow([
|
| 426 |
+
'sample_index', 'conversation_cut', 'emotion_type', 'problem_type',
|
| 427 |
+
'Emotion', 'Distortions', 'Empathy_Level',
|
| 428 |
+
'review_status', 'review_comment', 'is_edited'
|
| 429 |
+
])
|
| 430 |
+
for r in export_rows:
|
| 431 |
+
writer.writerow([
|
| 432 |
+
r['sample_index'], r['conversation_cut'], r['emotion_type'], r['problem_type'],
|
| 433 |
+
_json.dumps(r['Emotion'], ensure_ascii=False) if r['Emotion'] else '',
|
| 434 |
+
_json.dumps(r['Distortions'], ensure_ascii=False) if r['Distortions'] else '',
|
| 435 |
+
_json.dumps(r['Empathy_Level'], ensure_ascii=False) if r['Empathy_Level'] else '',
|
| 436 |
+
r['review_status'], r['review_comment'], r['is_edited'],
|
| 437 |
+
])
|
| 438 |
+
resp = app.response_class(
|
| 439 |
+
output.getvalue(),
|
| 440 |
+
mimetype='text/csv',
|
| 441 |
+
headers={'Content-Disposition': f'attachment; filename=export_{csv_file.filename}'}
|
| 442 |
+
)
|
| 443 |
+
return resp
|
| 444 |
+
else:
|
| 445 |
+
return jsonify(export_rows)
|
| 446 |
+
|
| 447 |
+
# ── Init DB ─────────────────────────────────────────────────────────────────
|
| 448 |
+
def init_db():
|
| 449 |
+
"""Create tables and seed admin user."""
|
| 450 |
+
db.create_all()
|
| 451 |
+
if not User.query.filter_by(username='admin').first():
|
| 452 |
+
admin = User(
|
| 453 |
+
username='admin',
|
| 454 |
+
password_hash=hash_password('admin123'),
|
| 455 |
+
role='admin',
|
| 456 |
+
)
|
| 457 |
+
db.session.add(admin)
|
| 458 |
+
db.session.commit()
|
| 459 |
+
print(' ✓ Created default admin user (admin / admin123)')
|
| 460 |
+
|
| 461 |
+
# Initial CSV scan
|
| 462 |
+
os.makedirs(CSV_FOLDER, exist_ok=True)
|
| 463 |
+
sync_csv_files()
|
| 464 |
+
file_count = CsvFile.query.count()
|
| 465 |
+
print(f' ✓ Found {file_count} CSV file(s) in {CSV_FOLDER}')
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
if __name__ == '__main__':
|
| 469 |
+
with app.app_context():
|
| 470 |
+
init_db()
|
| 471 |
+
print(f'\n 🚀 Server running at http://localhost:5000\n')
|
| 472 |
+
app.run(debug=True, host='0.0.0.0', port=5000)
|
csv_data/ESConv-100samples-Annotated-gpt-4o-mini.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
csv_utils.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSV parsing utilities for the Annotation Management System."""
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def parse_csv_file(filepath):
|
| 9 |
+
"""Parse a CSV file and return list of row dicts with only conversation samples."""
|
| 10 |
+
rows = []
|
| 11 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 12 |
+
reader = csv.DictReader(f)
|
| 13 |
+
for row in reader:
|
| 14 |
+
# Only include rows that have conversation_cut data
|
| 15 |
+
if row.get('conversation_cut', '').strip():
|
| 16 |
+
rows.append(dict(row))
|
| 17 |
+
return rows
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def count_samples(filepath):
|
| 21 |
+
"""Count the number of valid conversation samples in a CSV file."""
|
| 22 |
+
count = 0
|
| 23 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 24 |
+
reader = csv.DictReader(f)
|
| 25 |
+
for row in reader:
|
| 26 |
+
if row.get('conversation_cut', '').strip():
|
| 27 |
+
count += 1
|
| 28 |
+
return count
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_sample(filepath, index):
|
| 32 |
+
"""Get a specific sample by index from a CSV file."""
|
| 33 |
+
rows = parse_csv_file(filepath)
|
| 34 |
+
if 0 <= index < len(rows):
|
| 35 |
+
return rows[index]
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def scan_csv_folder(folder_path):
|
| 40 |
+
"""Scan a folder and return list of CSV file info dicts."""
|
| 41 |
+
files = []
|
| 42 |
+
if not os.path.isdir(folder_path):
|
| 43 |
+
return files
|
| 44 |
+
for fname in sorted(os.listdir(folder_path)):
|
| 45 |
+
if fname.lower().endswith('.csv'):
|
| 46 |
+
fpath = os.path.join(folder_path, fname)
|
| 47 |
+
files.append({
|
| 48 |
+
'filename': fname,
|
| 49 |
+
'filepath': fpath,
|
| 50 |
+
'size_bytes': os.path.getsize(fpath),
|
| 51 |
+
})
|
| 52 |
+
return files
|
index.html
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>ESConv Annotation Review Tool</title>
|
| 7 |
+
<meta name="description" content="Review and validate LLM-generated annotations for emotional support conversations including Emotion, Cognitive Distortions, and Empathy Level dimensions." />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
| 11 |
+
<link rel="stylesheet" href="style.css" />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<!-- Header -->
|
| 15 |
+
<header class="header">
|
| 16 |
+
<div class="header-inner">
|
| 17 |
+
<div class="header-logo">
|
| 18 |
+
<div class="logo-icon">
|
| 19 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 20 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 21 |
+
</svg>
|
| 22 |
+
</div>
|
| 23 |
+
<div>
|
| 24 |
+
<h1>ESConv Annotation Review</h1>
|
| 25 |
+
<p>LLM Annotation Quality Assessment</p>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="header-controls">
|
| 29 |
+
<div class="file-upload-area" id="fileUploadArea">
|
| 30 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 31 |
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
| 32 |
+
</svg>
|
| 33 |
+
<span id="fileLabel">Load CSV File</span>
|
| 34 |
+
<input type="file" id="csvInput" accept=".csv" />
|
| 35 |
+
</div>
|
| 36 |
+
<div class="sample-counter" id="sampleCounter" style="display:none">
|
| 37 |
+
<span id="currentIdx">1</span> / <span id="totalSamples">0</span>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</header>
|
| 42 |
+
|
| 43 |
+
<!-- Main Layout -->
|
| 44 |
+
<main class="main-layout" id="mainLayout" style="display:none">
|
| 45 |
+
|
| 46 |
+
<!-- Left: Navigation Panel -->
|
| 47 |
+
<aside class="nav-panel">
|
| 48 |
+
<div class="nav-panel-header">
|
| 49 |
+
<h2>Samples</h2>
|
| 50 |
+
<div class="search-box">
|
| 51 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 52 |
+
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
| 53 |
+
</svg>
|
| 54 |
+
<input type="text" id="searchInput" placeholder="Filter by emotion..." />
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="sample-list" id="sampleList"></div>
|
| 58 |
+
</aside>
|
| 59 |
+
|
| 60 |
+
<!-- Center: Conversation View -->
|
| 61 |
+
<section class="conversation-panel">
|
| 62 |
+
<div class="conv-header">
|
| 63 |
+
<div class="conv-meta" id="convMeta"></div>
|
| 64 |
+
<div class="legend">
|
| 65 |
+
<span class="legend-item"><span class="legend-dot speaker"></span>Speaker</span>
|
| 66 |
+
<span class="legend-item"><span class="legend-dot listener"></span>Listener</span>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="conversation-view" id="conversationView"></div>
|
| 70 |
+
</section>
|
| 71 |
+
|
| 72 |
+
<!-- Right: Annotation Panel -->
|
| 73 |
+
<aside class="annotation-panel">
|
| 74 |
+
<!-- Emotion Card -->
|
| 75 |
+
<div class="anno-card" id="emotionCard">
|
| 76 |
+
<div class="anno-card-header">
|
| 77 |
+
<div class="anno-card-icon emotion-icon">
|
| 78 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 79 |
+
<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>
|
| 80 |
+
</svg>
|
| 81 |
+
</div>
|
| 82 |
+
<h3>Emotion Analysis</h3>
|
| 83 |
+
</div>
|
| 84 |
+
<div id="emotionContent" class="anno-content"></div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<!-- Distortions Card -->
|
| 88 |
+
<div class="anno-card" id="distortionsCard">
|
| 89 |
+
<div class="anno-card-header">
|
| 90 |
+
<div class="anno-card-icon distortion-icon">
|
| 91 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 92 |
+
<path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
| 93 |
+
</svg>
|
| 94 |
+
</div>
|
| 95 |
+
<h3>Cognitive Distortions</h3>
|
| 96 |
+
</div>
|
| 97 |
+
<div id="distortionsContent" class="anno-content"></div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- Empathy Card -->
|
| 101 |
+
<div class="anno-card" id="empathyCard">
|
| 102 |
+
<div class="anno-card-header">
|
| 103 |
+
<div class="anno-card-icon empathy-icon">
|
| 104 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 105 |
+
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
|
| 106 |
+
</svg>
|
| 107 |
+
</div>
|
| 108 |
+
<h3>Empathy Level</h3>
|
| 109 |
+
<div class="overall-score" id="overallScore"></div>
|
| 110 |
+
</div>
|
| 111 |
+
<div id="empathyContent" class="anno-content"></div>
|
| 112 |
+
</div>
|
| 113 |
+
</aside>
|
| 114 |
+
</main>
|
| 115 |
+
|
| 116 |
+
<!-- Welcome Screen -->
|
| 117 |
+
<div class="welcome-screen" id="welcomeScreen">
|
| 118 |
+
<div class="welcome-card">
|
| 119 |
+
<div class="welcome-icon">
|
| 120 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 121 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 122 |
+
</svg>
|
| 123 |
+
</div>
|
| 124 |
+
<h2>Welcome to ESConv Annotation Review</h2>
|
| 125 |
+
<p>Load your annotated CSV file to begin reviewing LLM-generated labels for emotional support conversations.</p>
|
| 126 |
+
<div class="welcome-features">
|
| 127 |
+
<div class="feature-item">
|
| 128 |
+
<span class="feature-dot" style="background: var(--emotion-color)"></span>
|
| 129 |
+
<span>Emotion classification with intensity</span>
|
| 130 |
+
</div>
|
| 131 |
+
<div class="feature-item">
|
| 132 |
+
<span class="feature-dot" style="background: var(--distortion-color)"></span>
|
| 133 |
+
<span>Cognitive distortion detection</span>
|
| 134 |
+
</div>
|
| 135 |
+
<div class="feature-item">
|
| 136 |
+
<span class="feature-dot" style="background: var(--empathy-color)"></span>
|
| 137 |
+
<span>Multi-dimensional empathy scoring</span>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="feature-item">
|
| 140 |
+
<span class="feature-dot" style="background: var(--highlight-color)"></span>
|
| 141 |
+
<span>Interactive evidence highlighting</span>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
<label class="welcome-upload-btn" for="csvInput">
|
| 145 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 146 |
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
| 147 |
+
</svg>
|
| 148 |
+
Load CSV File
|
| 149 |
+
</label>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<!-- Tooltip -->
|
| 154 |
+
<div class="tooltip" id="tooltip">
|
| 155 |
+
<div class="tooltip-arrow"></div>
|
| 156 |
+
<div class="tooltip-content" id="tooltipContent"></div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<!-- Navigation Arrows -->
|
| 160 |
+
<div class="nav-arrows" id="navArrows" style="display:none">
|
| 161 |
+
<button class="nav-btn" id="prevBtn" title="Previous sample">
|
| 162 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 163 |
+
<path d="M15 18l-6-6 6-6"/>
|
| 164 |
+
</svg>
|
| 165 |
+
</button>
|
| 166 |
+
<button class="nav-btn" id="nextBtn" title="Next sample">
|
| 167 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 168 |
+
<path d="M9 18l6-6-6-6"/>
|
| 169 |
+
</svg>
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<script src="app.js"></script>
|
| 174 |
+
</body>
|
| 175 |
+
</html>
|
models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy models for the Annotation Management System."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 5 |
+
|
| 6 |
+
db = SQLAlchemy()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class User(db.Model):
|
| 10 |
+
__tablename__ = 'users'
|
| 11 |
+
|
| 12 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 13 |
+
username = db.Column(db.String(80), unique=True, nullable=False)
|
| 14 |
+
password_hash = db.Column(db.String(128), nullable=False)
|
| 15 |
+
role = db.Column(db.String(20), nullable=False, default='annotator') # 'admin' or 'annotator'
|
| 16 |
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
| 17 |
+
|
| 18 |
+
assignments = db.relationship('Assignment', backref='user', lazy=True, cascade='all, delete-orphan')
|
| 19 |
+
|
| 20 |
+
def to_dict(self):
|
| 21 |
+
return {
|
| 22 |
+
'id': self.id,
|
| 23 |
+
'username': self.username,
|
| 24 |
+
'role': self.role,
|
| 25 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class CsvFile(db.Model):
|
| 30 |
+
__tablename__ = 'csv_files'
|
| 31 |
+
|
| 32 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 33 |
+
filename = db.Column(db.String(255), unique=True, nullable=False)
|
| 34 |
+
filepath = db.Column(db.String(512), nullable=False)
|
| 35 |
+
total_samples = db.Column(db.Integer, default=0)
|
| 36 |
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
| 37 |
+
|
| 38 |
+
assignments = db.relationship('Assignment', backref='csv_file', lazy=True, cascade='all, delete-orphan')
|
| 39 |
+
|
| 40 |
+
def to_dict(self):
|
| 41 |
+
return {
|
| 42 |
+
'id': self.id,
|
| 43 |
+
'filename': self.filename,
|
| 44 |
+
'total_samples': self.total_samples,
|
| 45 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Assignment(db.Model):
|
| 50 |
+
__tablename__ = 'assignments'
|
| 51 |
+
|
| 52 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 53 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 54 |
+
file_id = db.Column(db.Integer, db.ForeignKey('csv_files.id'), nullable=False)
|
| 55 |
+
assigned_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
| 56 |
+
|
| 57 |
+
reviews = db.relationship('Review', backref='assignment', lazy=True, cascade='all, delete-orphan')
|
| 58 |
+
|
| 59 |
+
# Prevent duplicate assignments
|
| 60 |
+
__table_args__ = (db.UniqueConstraint('user_id', 'file_id', name='uq_user_file'),)
|
| 61 |
+
|
| 62 |
+
def to_dict(self):
|
| 63 |
+
reviewed_count = Review.query.filter_by(assignment_id=self.id).count()
|
| 64 |
+
total = self.csv_file.total_samples if self.csv_file else 0
|
| 65 |
+
return {
|
| 66 |
+
'id': self.id,
|
| 67 |
+
'user_id': self.user_id,
|
| 68 |
+
'username': self.user.username if self.user else None,
|
| 69 |
+
'file_id': self.file_id,
|
| 70 |
+
'filename': self.csv_file.filename if self.csv_file else None,
|
| 71 |
+
'total_samples': total,
|
| 72 |
+
'reviewed_count': reviewed_count,
|
| 73 |
+
'progress': round((reviewed_count / total * 100), 1) if total > 0 else 0,
|
| 74 |
+
'assigned_at': self.assigned_at.isoformat() if self.assigned_at else None,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class Review(db.Model):
|
| 79 |
+
__tablename__ = 'reviews'
|
| 80 |
+
|
| 81 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 82 |
+
assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=False)
|
| 83 |
+
sample_index = db.Column(db.Integer, nullable=False)
|
| 84 |
+
status = db.Column(db.String(20), nullable=False) # 'accept' or 'reject'
|
| 85 |
+
comment = db.Column(db.Text, default='')
|
| 86 |
+
# JSON string storing annotator-edited annotation data (emotion/distortions/empathy)
|
| 87 |
+
annotations_edit = db.Column(db.Text, default=None)
|
| 88 |
+
reviewed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
| 89 |
+
|
| 90 |
+
# One review per sample per assignment
|
| 91 |
+
__table_args__ = (db.UniqueConstraint('assignment_id', 'sample_index', name='uq_assignment_sample'),)
|
| 92 |
+
|
| 93 |
+
def to_dict(self):
|
| 94 |
+
return {
|
| 95 |
+
'id': self.id,
|
| 96 |
+
'assignment_id': self.assignment_id,
|
| 97 |
+
'sample_index': self.sample_index,
|
| 98 |
+
'status': self.status,
|
| 99 |
+
'comment': self.comment,
|
| 100 |
+
'annotations_edit': self.annotations_edit, # raw JSON string
|
| 101 |
+
'reviewed_at': self.reviewed_at.isoformat() if self.reviewed_at else None,
|
| 102 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
flask-sqlalchemy
|
| 3 |
+
bcrypt
|
| 4 |
+
gunicorn
|
static/admin.css
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== Admin/Annotator Dashboard Styles ===== */
|
| 2 |
+
.admin-main {
|
| 3 |
+
margin-top: var(--header-h);
|
| 4 |
+
padding: 24px 32px;
|
| 5 |
+
max-width: 1200px;
|
| 6 |
+
margin-left: auto;
|
| 7 |
+
margin-right: auto;
|
| 8 |
+
min-height: calc(100vh - var(--header-h));
|
| 9 |
+
overflow-y: auto;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/* User badge in header */
|
| 13 |
+
.user-badge {
|
| 14 |
+
padding: 4px 12px;
|
| 15 |
+
background: rgba(88,166,255,.1);
|
| 16 |
+
border: 1px solid rgba(88,166,255,.25);
|
| 17 |
+
border-radius: 20px;
|
| 18 |
+
font-size: .78rem;
|
| 19 |
+
font-weight: 600;
|
| 20 |
+
color: var(--accent);
|
| 21 |
+
}
|
| 22 |
+
.logout-btn {
|
| 23 |
+
display: flex; align-items: center; gap: 6px;
|
| 24 |
+
padding: 6px 14px;
|
| 25 |
+
background: transparent;
|
| 26 |
+
border: 1px solid var(--border);
|
| 27 |
+
border-radius: var(--radius-sm);
|
| 28 |
+
color: var(--text-muted);
|
| 29 |
+
font-size: .8rem;
|
| 30 |
+
cursor: pointer;
|
| 31 |
+
transition: all .2s;
|
| 32 |
+
}
|
| 33 |
+
.logout-btn:hover { border-color: #f87171; color: #f87171; }
|
| 34 |
+
|
| 35 |
+
/* Tab Navigation */
|
| 36 |
+
.tab-nav {
|
| 37 |
+
display: flex;
|
| 38 |
+
gap: 4px;
|
| 39 |
+
margin-bottom: 24px;
|
| 40 |
+
border-bottom: 1px solid var(--border);
|
| 41 |
+
padding-bottom: 0;
|
| 42 |
+
}
|
| 43 |
+
.tab-btn {
|
| 44 |
+
padding: 10px 20px;
|
| 45 |
+
background: transparent;
|
| 46 |
+
border: none;
|
| 47 |
+
border-bottom: 2px solid transparent;
|
| 48 |
+
color: var(--text-muted);
|
| 49 |
+
font-size: .85rem;
|
| 50 |
+
font-weight: 500;
|
| 51 |
+
cursor: pointer;
|
| 52 |
+
transition: all .2s;
|
| 53 |
+
font-family: inherit;
|
| 54 |
+
}
|
| 55 |
+
.tab-btn:hover { color: var(--text-primary); }
|
| 56 |
+
.tab-btn.active {
|
| 57 |
+
color: var(--accent);
|
| 58 |
+
border-bottom-color: var(--accent);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* Tab Content */
|
| 62 |
+
.tab-content { display: none; }
|
| 63 |
+
.tab-content.active { display: block; }
|
| 64 |
+
|
| 65 |
+
/* Section Header */
|
| 66 |
+
.section-header {
|
| 67 |
+
display: flex;
|
| 68 |
+
align-items: center;
|
| 69 |
+
justify-content: space-between;
|
| 70 |
+
margin-bottom: 20px;
|
| 71 |
+
}
|
| 72 |
+
.section-header h2 {
|
| 73 |
+
font-size: 1.1rem;
|
| 74 |
+
font-weight: 700;
|
| 75 |
+
}
|
| 76 |
+
.section-header p {
|
| 77 |
+
font-size: .8rem;
|
| 78 |
+
color: var(--text-muted);
|
| 79 |
+
margin-left: 12px;
|
| 80 |
+
}
|
| 81 |
+
.section-header p code {
|
| 82 |
+
background: var(--bg-card);
|
| 83 |
+
padding: 2px 8px;
|
| 84 |
+
border-radius: 4px;
|
| 85 |
+
font-size: .75rem;
|
| 86 |
+
color: var(--accent);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* Buttons */
|
| 90 |
+
.primary-btn {
|
| 91 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 92 |
+
padding: 8px 16px;
|
| 93 |
+
background: linear-gradient(135deg, var(--accent), #388bfd);
|
| 94 |
+
color: #fff;
|
| 95 |
+
border: none;
|
| 96 |
+
border-radius: var(--radius-sm);
|
| 97 |
+
font-size: .82rem;
|
| 98 |
+
font-weight: 600;
|
| 99 |
+
cursor: pointer;
|
| 100 |
+
transition: opacity .2s;
|
| 101 |
+
font-family: inherit;
|
| 102 |
+
}
|
| 103 |
+
.primary-btn:hover { opacity: .85; }
|
| 104 |
+
.ghost-btn {
|
| 105 |
+
padding: 8px 16px;
|
| 106 |
+
background: transparent;
|
| 107 |
+
border: 1px solid var(--border);
|
| 108 |
+
border-radius: var(--radius-sm);
|
| 109 |
+
color: var(--text-secondary);
|
| 110 |
+
font-size: .82rem;
|
| 111 |
+
cursor: pointer;
|
| 112 |
+
transition: all .2s;
|
| 113 |
+
font-family: inherit;
|
| 114 |
+
}
|
| 115 |
+
.ghost-btn:hover { border-color: var(--text-muted); }
|
| 116 |
+
.danger-btn {
|
| 117 |
+
padding: 4px 10px;
|
| 118 |
+
background: rgba(248,113,113,.1);
|
| 119 |
+
border: 1px solid rgba(248,113,113,.25);
|
| 120 |
+
border-radius: var(--radius-sm);
|
| 121 |
+
color: #f87171;
|
| 122 |
+
font-size: .75rem;
|
| 123 |
+
cursor: pointer;
|
| 124 |
+
transition: all .2s;
|
| 125 |
+
font-family: inherit;
|
| 126 |
+
}
|
| 127 |
+
.danger-btn:hover { background: rgba(248,113,113,.2); }
|
| 128 |
+
|
| 129 |
+
/* Create Forms */
|
| 130 |
+
.create-form {
|
| 131 |
+
background: var(--bg-card);
|
| 132 |
+
border: 1px solid var(--border);
|
| 133 |
+
border-radius: var(--radius);
|
| 134 |
+
padding: 14px 18px;
|
| 135 |
+
margin-bottom: 20px;
|
| 136 |
+
}
|
| 137 |
+
.form-row {
|
| 138 |
+
display: flex;
|
| 139 |
+
gap: 10px;
|
| 140 |
+
align-items: center;
|
| 141 |
+
flex-wrap: wrap;
|
| 142 |
+
}
|
| 143 |
+
.form-row input, .form-row select {
|
| 144 |
+
padding: 8px 12px;
|
| 145 |
+
background: var(--bg-primary);
|
| 146 |
+
border: 1px solid var(--border);
|
| 147 |
+
border-radius: var(--radius-sm);
|
| 148 |
+
color: var(--text-primary);
|
| 149 |
+
font-size: .85rem;
|
| 150 |
+
outline: none;
|
| 151 |
+
min-width: 140px;
|
| 152 |
+
font-family: inherit;
|
| 153 |
+
}
|
| 154 |
+
.form-row input:focus, .form-row select:focus { border-color: var(--accent); }
|
| 155 |
+
.form-error {
|
| 156 |
+
color: #f87171;
|
| 157 |
+
font-size: .78rem;
|
| 158 |
+
margin-top: 8px;
|
| 159 |
+
min-height: 1em;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* Data Table */
|
| 163 |
+
.table-container { overflow-x: auto; }
|
| 164 |
+
.data-table {
|
| 165 |
+
width: 100%;
|
| 166 |
+
border-collapse: collapse;
|
| 167 |
+
font-size: .85rem;
|
| 168 |
+
}
|
| 169 |
+
.data-table th {
|
| 170 |
+
text-align: left;
|
| 171 |
+
padding: 10px 14px;
|
| 172 |
+
background: var(--bg-card);
|
| 173 |
+
color: var(--text-muted);
|
| 174 |
+
font-size: .72rem;
|
| 175 |
+
font-weight: 600;
|
| 176 |
+
text-transform: uppercase;
|
| 177 |
+
letter-spacing: .06em;
|
| 178 |
+
border-bottom: 1px solid var(--border);
|
| 179 |
+
}
|
| 180 |
+
.data-table td {
|
| 181 |
+
padding: 10px 14px;
|
| 182 |
+
border-bottom: 1px solid var(--border-light);
|
| 183 |
+
color: var(--text-secondary);
|
| 184 |
+
}
|
| 185 |
+
.data-table tbody tr:hover { background: var(--bg-hover); }
|
| 186 |
+
.role-badge {
|
| 187 |
+
padding: 2px 8px;
|
| 188 |
+
border-radius: 10px;
|
| 189 |
+
font-size: .72rem;
|
| 190 |
+
font-weight: 600;
|
| 191 |
+
}
|
| 192 |
+
.role-admin { background: rgba(167,139,250,.12); color: #a78bfa; }
|
| 193 |
+
.role-annotator { background: rgba(52,211,153,.12); color: #34d399; }
|
| 194 |
+
|
| 195 |
+
/* File Cards */
|
| 196 |
+
.card-grid {
|
| 197 |
+
display: grid;
|
| 198 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 199 |
+
gap: 14px;
|
| 200 |
+
}
|
| 201 |
+
.file-card {
|
| 202 |
+
background: var(--bg-card);
|
| 203 |
+
border: 1px solid var(--border);
|
| 204 |
+
border-radius: var(--radius);
|
| 205 |
+
padding: 18px;
|
| 206 |
+
cursor: pointer;
|
| 207 |
+
transition: all .2s;
|
| 208 |
+
}
|
| 209 |
+
.file-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); }
|
| 210 |
+
.file-card-header {
|
| 211 |
+
display: flex;
|
| 212 |
+
align-items: center;
|
| 213 |
+
gap: 10px;
|
| 214 |
+
margin-bottom: 12px;
|
| 215 |
+
}
|
| 216 |
+
.file-card-name {
|
| 217 |
+
font-weight: 600;
|
| 218 |
+
font-size: .85rem;
|
| 219 |
+
word-break: break-all;
|
| 220 |
+
}
|
| 221 |
+
.file-card-stats {
|
| 222 |
+
display: flex;
|
| 223 |
+
justify-content: space-between;
|
| 224 |
+
font-size: .78rem;
|
| 225 |
+
color: var(--text-muted);
|
| 226 |
+
margin-bottom: 8px;
|
| 227 |
+
}
|
| 228 |
+
.progress-bar {
|
| 229 |
+
height: 5px;
|
| 230 |
+
background: var(--border);
|
| 231 |
+
border-radius: 3px;
|
| 232 |
+
overflow: hidden;
|
| 233 |
+
margin-bottom: 10px;
|
| 234 |
+
}
|
| 235 |
+
.progress-fill {
|
| 236 |
+
height: 100%;
|
| 237 |
+
border-radius: 3px;
|
| 238 |
+
transition: width .4s ease;
|
| 239 |
+
}
|
| 240 |
+
.completed-badge {
|
| 241 |
+
font-size: .75rem;
|
| 242 |
+
color: #34d399;
|
| 243 |
+
font-weight: 600;
|
| 244 |
+
text-align: center;
|
| 245 |
+
}
|
| 246 |
+
.continue-badge {
|
| 247 |
+
font-size: .75rem;
|
| 248 |
+
color: var(--accent);
|
| 249 |
+
font-weight: 600;
|
| 250 |
+
text-align: center;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/* Empty State */
|
| 254 |
+
.empty-state {
|
| 255 |
+
text-align: center;
|
| 256 |
+
padding: 60px 20px;
|
| 257 |
+
color: var(--text-muted);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* Progress in table */
|
| 261 |
+
.progress-cell {
|
| 262 |
+
display: flex;
|
| 263 |
+
align-items: center;
|
| 264 |
+
gap: 8px;
|
| 265 |
+
}
|
| 266 |
+
.progress-cell .progress-bar {
|
| 267 |
+
width: 80px;
|
| 268 |
+
margin-bottom: 0;
|
| 269 |
+
}
|
| 270 |
+
.progress-text {
|
| 271 |
+
font-size: .75rem;
|
| 272 |
+
font-variant-numeric: tabular-nums;
|
| 273 |
+
min-width: 60px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/* Review controls on review page */
|
| 277 |
+
.review-card { border-color: rgba(88,166,255,.3); }
|
| 278 |
+
.review-buttons {
|
| 279 |
+
display: flex;
|
| 280 |
+
gap: 8px;
|
| 281 |
+
margin-bottom: 10px;
|
| 282 |
+
}
|
| 283 |
+
.review-btn {
|
| 284 |
+
flex: 1;
|
| 285 |
+
padding: 10px;
|
| 286 |
+
border: 2px solid var(--border);
|
| 287 |
+
border-radius: var(--radius-sm);
|
| 288 |
+
background: transparent;
|
| 289 |
+
font-size: .85rem;
|
| 290 |
+
font-weight: 600;
|
| 291 |
+
cursor: pointer;
|
| 292 |
+
transition: all .15s;
|
| 293 |
+
font-family: inherit;
|
| 294 |
+
}
|
| 295 |
+
.accept-btn { color: var(--text-muted); }
|
| 296 |
+
.accept-btn:hover, .accept-btn.active {
|
| 297 |
+
border-color: #34d399;
|
| 298 |
+
background: rgba(52,211,153,.1);
|
| 299 |
+
color: #34d399;
|
| 300 |
+
}
|
| 301 |
+
.reject-btn { color: var(--text-muted); }
|
| 302 |
+
.reject-btn:hover, .reject-btn.active {
|
| 303 |
+
border-color: #f87171;
|
| 304 |
+
background: rgba(248,113,113,.1);
|
| 305 |
+
color: #f87171;
|
| 306 |
+
}
|
| 307 |
+
.review-comment {
|
| 308 |
+
width: 100%;
|
| 309 |
+
padding: 10px;
|
| 310 |
+
background: var(--bg-primary);
|
| 311 |
+
border: 1px solid var(--border);
|
| 312 |
+
border-radius: var(--radius-sm);
|
| 313 |
+
color: var(--text-primary);
|
| 314 |
+
font-size: .8rem;
|
| 315 |
+
font-family: inherit;
|
| 316 |
+
resize: vertical;
|
| 317 |
+
outline: none;
|
| 318 |
+
margin-bottom: 10px;
|
| 319 |
+
}
|
| 320 |
+
.review-comment:focus { border-color: var(--accent); }
|
| 321 |
+
.submit-review-btn {
|
| 322 |
+
width: 100%;
|
| 323 |
+
padding: 10px;
|
| 324 |
+
background: linear-gradient(135deg, var(--accent), #388bfd);
|
| 325 |
+
color: #fff;
|
| 326 |
+
border: none;
|
| 327 |
+
border-radius: var(--radius-sm);
|
| 328 |
+
font-weight: 600;
|
| 329 |
+
font-size: .85rem;
|
| 330 |
+
cursor: pointer;
|
| 331 |
+
transition: opacity .2s;
|
| 332 |
+
font-family: inherit;
|
| 333 |
+
}
|
| 334 |
+
.submit-review-btn:hover { opacity: .85; }
|
| 335 |
+
.submit-review-btn:disabled { opacity: .4; cursor: not-allowed; }
|
| 336 |
+
.review-status-badge {
|
| 337 |
+
margin-left: auto;
|
| 338 |
+
padding: 2px 10px;
|
| 339 |
+
border-radius: 10px;
|
| 340 |
+
font-size: .72rem;
|
| 341 |
+
font-weight: 700;
|
| 342 |
+
}
|
| 343 |
+
.review-status-accept { background: rgba(52,211,153,.12); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
|
| 344 |
+
.review-status-reject { background: rgba(248,113,113,.12); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
|
| 345 |
+
|
| 346 |
+
/* Sample list review status dots */
|
| 347 |
+
.sample-review-dot {
|
| 348 |
+
width: 7px; height: 7px;
|
| 349 |
+
border-radius: 50%;
|
| 350 |
+
display: inline-block;
|
| 351 |
+
margin-left: 4px;
|
| 352 |
+
}
|
| 353 |
+
.dot-accept { background: #34d399; }
|
| 354 |
+
.dot-reject { background: #f87171; }
|
| 355 |
+
.dot-pending { background: var(--border); }
|
| 356 |
+
|
| 357 |
+
/* ===== Edit Mode Styles ===== */
|
| 358 |
+
.edit-toggle-btn {
|
| 359 |
+
margin-left: auto;
|
| 360 |
+
padding: 3px 10px;
|
| 361 |
+
background: transparent;
|
| 362 |
+
border: 1px solid var(--border);
|
| 363 |
+
border-radius: var(--radius-sm);
|
| 364 |
+
color: var(--text-muted);
|
| 365 |
+
font-size: .72rem;
|
| 366 |
+
cursor: pointer;
|
| 367 |
+
transition: all .15s;
|
| 368 |
+
font-family: inherit;
|
| 369 |
+
display: flex; align-items: center; gap: 4px;
|
| 370 |
+
}
|
| 371 |
+
.edit-toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
|
| 372 |
+
.edit-toggle-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.1); }
|
| 373 |
+
|
| 374 |
+
.edited-badge {
|
| 375 |
+
padding: 2px 8px;
|
| 376 |
+
background: rgba(251,191,36,.12);
|
| 377 |
+
border: 1px solid rgba(251,191,36,.3);
|
| 378 |
+
border-radius: 10px;
|
| 379 |
+
font-size: .65rem;
|
| 380 |
+
font-weight: 700;
|
| 381 |
+
color: #fbbf24;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
/* Inline edit inputs */
|
| 385 |
+
.edit-field {
|
| 386 |
+
width: 100%;
|
| 387 |
+
background: var(--bg-primary);
|
| 388 |
+
border: 1px solid var(--border);
|
| 389 |
+
border-radius: var(--radius-sm);
|
| 390 |
+
color: var(--text-primary);
|
| 391 |
+
font-size: .82rem;
|
| 392 |
+
font-family: inherit;
|
| 393 |
+
outline: none;
|
| 394 |
+
transition: border-color .15s;
|
| 395 |
+
}
|
| 396 |
+
.edit-field:focus { border-color: var(--accent); }
|
| 397 |
+
input.edit-field { padding: 5px 9px; }
|
| 398 |
+
textarea.edit-field { padding: 6px 9px; resize: vertical; }
|
| 399 |
+
select.edit-field { padding: 5px 9px; }
|
| 400 |
+
|
| 401 |
+
.edit-row {
|
| 402 |
+
display: flex; align-items: center; gap: 6px;
|
| 403 |
+
margin-bottom: 6px;
|
| 404 |
+
}
|
| 405 |
+
.edit-label {
|
| 406 |
+
font-size: .68rem;
|
| 407 |
+
color: var(--text-muted);
|
| 408 |
+
text-transform: uppercase;
|
| 409 |
+
letter-spacing: .06em;
|
| 410 |
+
font-weight: 600;
|
| 411 |
+
margin-bottom: 3px;
|
| 412 |
+
}
|
| 413 |
+
.edit-group { margin-bottom: 8px; }
|
| 414 |
+
.edit-group:last-child { margin-bottom: 0; }
|
| 415 |
+
|
| 416 |
+
/* Evidence select mode */
|
| 417 |
+
.select-evidence-btn {
|
| 418 |
+
padding: 4px 10px;
|
| 419 |
+
background: rgba(251,191,36,.1);
|
| 420 |
+
border: 1px solid rgba(251,191,36,.3);
|
| 421 |
+
border-radius: var(--radius-sm);
|
| 422 |
+
color: #fbbf24;
|
| 423 |
+
font-size: .72rem;
|
| 424 |
+
cursor: pointer;
|
| 425 |
+
font-family: inherit;
|
| 426 |
+
white-space: nowrap;
|
| 427 |
+
transition: all .15s;
|
| 428 |
+
flex-shrink: 0;
|
| 429 |
+
}
|
| 430 |
+
.select-evidence-btn:hover { background: rgba(251,191,36,.2); }
|
| 431 |
+
.select-evidence-btn.selecting { background: rgba(251,191,36,.25); border-color: #fbbf24; animation: pulse-border .8s infinite; }
|
| 432 |
+
@keyframes pulse-border { 0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,.4); } 50% { box-shadow: 0 0 0 4px rgba(251,191,36,0); } }
|
| 433 |
+
|
| 434 |
+
/* Conversation selection mode overlay */
|
| 435 |
+
.conversation-panel.selecting-mode .conversation-view {
|
| 436 |
+
cursor: text;
|
| 437 |
+
outline: 2px dashed rgba(251,191,36,.5);
|
| 438 |
+
outline-offset: -4px;
|
| 439 |
+
}
|
| 440 |
+
.selection-hint {
|
| 441 |
+
position: sticky;
|
| 442 |
+
top: 0;
|
| 443 |
+
z-index: 10;
|
| 444 |
+
background: rgba(251,191,36,.15);
|
| 445 |
+
border: 1px solid rgba(251,191,36,.4);
|
| 446 |
+
border-radius: var(--radius-sm);
|
| 447 |
+
padding: 6px 12px;
|
| 448 |
+
font-size: .78rem;
|
| 449 |
+
color: #fbbf24;
|
| 450 |
+
text-align: center;
|
| 451 |
+
margin-bottom: 8px;
|
| 452 |
+
display: none;
|
| 453 |
+
}
|
| 454 |
+
.selection-hint.visible { display: block; }
|
| 455 |
+
|
| 456 |
+
/* Distortion edit item */
|
| 457 |
+
.distortion-item.editing {
|
| 458 |
+
border-color: var(--distortion-color);
|
| 459 |
+
background: var(--distortion-bg);
|
| 460 |
+
}
|
| 461 |
+
.empathy-item.editing {
|
| 462 |
+
border-color: rgba(52,211,153,.5);
|
| 463 |
+
background: rgba(52,211,153,.08);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
/* Score slider */
|
| 467 |
+
.score-slider { width: 100%; accent-color: var(--empathy-color); }
|
| 468 |
+
.score-display { font-weight: 700; font-size: .85rem; min-width: 20px; }
|
| 469 |
+
|
| 470 |
+
/* Add/remove item buttons */
|
| 471 |
+
.add-item-btn {
|
| 472 |
+
width: 100%; padding: 7px;
|
| 473 |
+
background: transparent;
|
| 474 |
+
border: 1px dashed var(--border);
|
| 475 |
+
border-radius: var(--radius-sm);
|
| 476 |
+
color: var(--text-muted);
|
| 477 |
+
font-size: .78rem;
|
| 478 |
+
cursor: pointer; font-family: inherit;
|
| 479 |
+
transition: all .15s;
|
| 480 |
+
margin-top: 6px;
|
| 481 |
+
}
|
| 482 |
+
.add-item-btn:hover { border-color: var(--accent); color: var(--accent); }
|
| 483 |
+
.remove-item-btn {
|
| 484 |
+
padding: 2px 7px;
|
| 485 |
+
background: rgba(248,113,113,.08);
|
| 486 |
+
border: 1px solid rgba(248,113,113,.2);
|
| 487 |
+
border-radius: 4px;
|
| 488 |
+
color: #f87171;
|
| 489 |
+
font-size: .7rem;
|
| 490 |
+
cursor: pointer; font-family: inherit;
|
| 491 |
+
flex-shrink: 0;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/* Save annotation button */
|
| 495 |
+
.save-anno-btn {
|
| 496 |
+
width: 100%; padding: 8px;
|
| 497 |
+
background: linear-gradient(135deg,#fbbf24,#f59e0b);
|
| 498 |
+
color: #1a1200;
|
| 499 |
+
border: none; border-radius: var(--radius-sm);
|
| 500 |
+
font-weight: 700; font-size: .82rem;
|
| 501 |
+
cursor: pointer; font-family: inherit;
|
| 502 |
+
margin-top: 8px; transition: opacity .15s;
|
| 503 |
+
}
|
| 504 |
+
.save-anno-btn:hover { opacity: .9; }
|
| 505 |
+
.save-anno-btn.saved { background: linear-gradient(135deg,#34d399,#059669); color: #fff; }
|
| 506 |
+
|
| 507 |
+
.reset-anno-btn {
|
| 508 |
+
width: 100%; padding: 6px;
|
| 509 |
+
background: transparent;
|
| 510 |
+
border: 1px solid var(--border);
|
| 511 |
+
border-radius: var(--radius-sm);
|
| 512 |
+
color: var(--text-muted);
|
| 513 |
+
font-size: .75rem; cursor: pointer; font-family: inherit;
|
| 514 |
+
margin-top: 4px; transition: all .15s;
|
| 515 |
+
}
|
| 516 |
+
.reset-anno-btn:hover { border-color: #f87171; color: #f87171; }
|
static/admin.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ===== Admin Dashboard JS =====
|
| 2 |
+
|
| 3 |
+
// ── Tab switching ──
|
| 4 |
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
| 5 |
+
btn.addEventListener('click', () => {
|
| 6 |
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
| 7 |
+
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
| 8 |
+
btn.classList.add('active');
|
| 9 |
+
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
| 10 |
+
});
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// ── Logout ──
|
| 14 |
+
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
| 15 |
+
await fetch('/api/logout', { method: 'POST' });
|
| 16 |
+
window.location.href = '/login';
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
// ── User info ──
|
| 20 |
+
fetch('/api/me').then(r => r.json()).then(u => {
|
| 21 |
+
document.getElementById('userBadge').textContent = u.username;
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// ── State ──
|
| 25 |
+
let allUsers = [];
|
| 26 |
+
let allFiles = [];
|
| 27 |
+
|
| 28 |
+
// ── Files ──
|
| 29 |
+
async function loadFiles() {
|
| 30 |
+
const res = await fetch('/api/files');
|
| 31 |
+
allFiles = await res.json();
|
| 32 |
+
const grid = document.getElementById('filesList');
|
| 33 |
+
if (allFiles.length === 0) {
|
| 34 |
+
grid.innerHTML = '<div class="empty-state"><p>No CSV files found in the data folder.</p></div>';
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
grid.innerHTML = allFiles.map(f => {
|
| 38 |
+
return `
|
| 39 |
+
<div class="file-card" style="cursor:default">
|
| 40 |
+
<div class="file-card-header">
|
| 41 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent);flex-shrink:0">
|
| 42 |
+
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
| 43 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 44 |
+
</svg>
|
| 45 |
+
<span class="file-card-name">${f.filename}</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="file-card-stats">
|
| 48 |
+
<span>${f.total_samples} samples</span>
|
| 49 |
+
<span>${f.total_assignees} assignee(s)</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="file-card-stats" style="margin-top:4px">
|
| 52 |
+
<span>${f.total_reviews} total reviews</span>
|
| 53 |
+
</div>
|
| 54 |
+
</div>`;
|
| 55 |
+
}).join('');
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ── Users ──
|
| 59 |
+
async function loadUsers() {
|
| 60 |
+
const res = await fetch('/api/users');
|
| 61 |
+
allUsers = await res.json();
|
| 62 |
+
const tbody = document.getElementById('usersBody');
|
| 63 |
+
tbody.innerHTML = allUsers.map(u => {
|
| 64 |
+
const roleClass = u.role === 'admin' ? 'role-admin' : 'role-annotator';
|
| 65 |
+
const created = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
|
| 66 |
+
return `<tr>
|
| 67 |
+
<td>${u.id}</td>
|
| 68 |
+
<td style="font-weight:600;color:var(--text-primary)">${u.username}</td>
|
| 69 |
+
<td><span class="role-badge ${roleClass}">${u.role}</span></td>
|
| 70 |
+
<td>${created}</td>
|
| 71 |
+
<td>${u.role !== 'admin' ? `<button class="danger-btn" onclick="deleteUser(${u.id})">Delete</button>` : '—'}</td>
|
| 72 |
+
</tr>`;
|
| 73 |
+
}).join('');
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// ── Create User ──
|
| 77 |
+
document.getElementById('addUserBtn').addEventListener('click', () => {
|
| 78 |
+
document.getElementById('createUserForm').style.display = 'block';
|
| 79 |
+
document.getElementById('newUsername').focus();
|
| 80 |
+
});
|
| 81 |
+
document.getElementById('cancelUserBtn').addEventListener('click', () => {
|
| 82 |
+
document.getElementById('createUserForm').style.display = 'none';
|
| 83 |
+
document.getElementById('userFormError').textContent = '';
|
| 84 |
+
});
|
| 85 |
+
document.getElementById('submitUserBtn').addEventListener('click', async () => {
|
| 86 |
+
const username = document.getElementById('newUsername').value.trim();
|
| 87 |
+
const password = document.getElementById('newPassword').value;
|
| 88 |
+
const role = document.getElementById('newRole').value;
|
| 89 |
+
const errEl = document.getElementById('userFormError');
|
| 90 |
+
errEl.textContent = '';
|
| 91 |
+
const res = await fetch('/api/users', {
|
| 92 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 93 |
+
body: JSON.stringify({ username, password, role })
|
| 94 |
+
});
|
| 95 |
+
const data = await res.json();
|
| 96 |
+
if (!res.ok) { errEl.textContent = data.error; return; }
|
| 97 |
+
document.getElementById('createUserForm').style.display = 'none';
|
| 98 |
+
document.getElementById('newUsername').value = '';
|
| 99 |
+
document.getElementById('newPassword').value = '';
|
| 100 |
+
loadUsers();
|
| 101 |
+
loadAssignDropdowns();
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
async function deleteUser(id) {
|
| 105 |
+
if (!confirm('Delete this user?')) return;
|
| 106 |
+
await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
| 107 |
+
loadUsers();
|
| 108 |
+
loadAssignments();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ── Assignments ──
|
| 112 |
+
async function loadAssignments() {
|
| 113 |
+
const res = await fetch('/api/assignments');
|
| 114 |
+
const data = await res.json();
|
| 115 |
+
const tbody = document.getElementById('assignBody');
|
| 116 |
+
if (data.length === 0) {
|
| 117 |
+
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">No assignments yet</td></tr>';
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
tbody.innerHTML = data.map(a => {
|
| 121 |
+
const pct = a.progress;
|
| 122 |
+
const barColor = pct >= 100 ? '#34d399' : pct > 50 ? '#fbbf24' : '#60a5fa';
|
| 123 |
+
const date = a.assigned_at ? new Date(a.assigned_at).toLocaleDateString() : '—';
|
| 124 |
+
return `<tr>
|
| 125 |
+
<td style="font-weight:600;color:var(--text-primary)">${a.username}</td>
|
| 126 |
+
<td>${a.filename}</td>
|
| 127 |
+
<td>
|
| 128 |
+
<div class="progress-cell">
|
| 129 |
+
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${barColor}"></div></div>
|
| 130 |
+
<span class="progress-text" style="color:${barColor}">${a.reviewed_count}/${a.total_samples} (${pct}%)</span>
|
| 131 |
+
</div>
|
| 132 |
+
</td>
|
| 133 |
+
<td>${date}</td>
|
| 134 |
+
<td>
|
| 135 |
+
<button class="danger-btn" onclick="deleteAssignment(${a.id})">Remove</button>
|
| 136 |
+
</td>
|
| 137 |
+
</tr>`;
|
| 138 |
+
}).join('');
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
async function loadAssignDropdowns() {
|
| 142 |
+
// Populate user dropdown
|
| 143 |
+
const userSel = document.getElementById('assignUser');
|
| 144 |
+
const fileSel = document.getElementById('assignFile');
|
| 145 |
+
const usersRes = await fetch('/api/users');
|
| 146 |
+
const users = await usersRes.json();
|
| 147 |
+
const filesRes = await fetch('/api/files');
|
| 148 |
+
const files = await filesRes.json();
|
| 149 |
+
userSel.innerHTML = '<option value="">Select User...</option>' +
|
| 150 |
+
users.filter(u => u.role === 'annotator').map(u => `<option value="${u.id}">${u.username}</option>`).join('');
|
| 151 |
+
fileSel.innerHTML = '<option value="">Select File...</option>' +
|
| 152 |
+
files.map(f => `<option value="${f.id}">${f.filename} (${f.total_samples} samples)</option>`).join('');
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
document.getElementById('addAssignBtn').addEventListener('click', () => {
|
| 156 |
+
document.getElementById('createAssignForm').style.display = 'block';
|
| 157 |
+
loadAssignDropdowns();
|
| 158 |
+
});
|
| 159 |
+
document.getElementById('cancelAssignBtn').addEventListener('click', () => {
|
| 160 |
+
document.getElementById('createAssignForm').style.display = 'none';
|
| 161 |
+
document.getElementById('assignFormError').textContent = '';
|
| 162 |
+
});
|
| 163 |
+
document.getElementById('submitAssignBtn').addEventListener('click', async () => {
|
| 164 |
+
const user_id = document.getElementById('assignUser').value;
|
| 165 |
+
const file_id = document.getElementById('assignFile').value;
|
| 166 |
+
const errEl = document.getElementById('assignFormError');
|
| 167 |
+
errEl.textContent = '';
|
| 168 |
+
if (!user_id || !file_id) { errEl.textContent = 'Select both user and file'; return; }
|
| 169 |
+
const res = await fetch('/api/assignments', {
|
| 170 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 171 |
+
body: JSON.stringify({ user_id: parseInt(user_id), file_id: parseInt(file_id) })
|
| 172 |
+
});
|
| 173 |
+
const data = await res.json();
|
| 174 |
+
if (!res.ok) { errEl.textContent = data.error; return; }
|
| 175 |
+
document.getElementById('createAssignForm').style.display = 'none';
|
| 176 |
+
loadAssignments();
|
| 177 |
+
loadFiles();
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
async function deleteAssignment(id) {
|
| 181 |
+
if (!confirm('Remove this assignment?')) return;
|
| 182 |
+
await fetch(`/api/assignments/${id}`, { method: 'DELETE' });
|
| 183 |
+
loadAssignments();
|
| 184 |
+
loadFiles();
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// ── Init ──
|
| 188 |
+
loadFiles();
|
| 189 |
+
loadUsers();
|
| 190 |
+
loadAssignments();
|
static/app.js
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ===== State =====
|
| 2 |
+
let allData=[], filteredData=[], currentIndex=0;
|
| 3 |
+
let assignmentId=null, reviewsMap={}, currentReviewStatus=null;
|
| 4 |
+
let editMode={emotion:false, distortions:false, empathy:false};
|
| 5 |
+
let editData={emotion:null, distortions:null, empathy:null}; // working copies
|
| 6 |
+
let selectingEvidence=null; // {card, itemIdx, field}
|
| 7 |
+
|
| 8 |
+
const DIST_COLORS=['#fb923c','#f87171','#fbbf24','#a78bfa','#34d399'];
|
| 9 |
+
const EMP_COLORS=['#60a5fa','#a78bfa','#34d399','#fbbf24','#fb923c','#f87171','#818cf8','#2dd4bf','#e879f9'];
|
| 10 |
+
const EMOTION_OPTS=['Fear','Sadness','Anger','Joy','Anxiety','Shame','Depression','Disgust','Surprise'];
|
| 11 |
+
const INTENSITY_OPTS=['Low','Medium','High'];
|
| 12 |
+
const DISTORTION_OPTS=[
|
| 13 |
+
'All-or-Nothing Thinking', 'Overgeneralization', 'Mental Filter',
|
| 14 |
+
'Disqualifying the Positive', 'Mind Reading', 'Fortune Telling',
|
| 15 |
+
'Catastrophizing', 'Minimization', 'Emotional Reasoning',
|
| 16 |
+
'Should Statements', 'Labeling', 'Personalization'
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
// ===== Helpers =====
|
| 20 |
+
function safeJSON(s){try{return JSON.parse(s);}catch(e){return null;}}
|
| 21 |
+
function escA(s){return (s||'').replace(/"/g,'"').replace(/'/g,''');}
|
| 22 |
+
function escH(s){return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
| 23 |
+
function parseAnno(row){
|
| 24 |
+
return {
|
| 25 |
+
emotion:safeJSON(row['Emotion']),
|
| 26 |
+
distortions:safeJSON(row['Distortions']),
|
| 27 |
+
empathy:safeJSON(row['Empathy_Level'])
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
function getEffectiveAnno(row){
|
| 31 |
+
const review=reviewsMap[currentIndex];
|
| 32 |
+
if(review&&review.annotations_edit){
|
| 33 |
+
const ed=safeJSON(review.annotations_edit);
|
| 34 |
+
if(ed) return ed;
|
| 35 |
+
}
|
| 36 |
+
return parseAnno(row);
|
| 37 |
+
}
|
| 38 |
+
function emotionCls(e){
|
| 39 |
+
if(!e)return 'emotion-default';
|
| 40 |
+
const l=e.toLowerCase();
|
| 41 |
+
if(l.includes('fear'))return 'emotion-fear';
|
| 42 |
+
if(l.includes('sad'))return 'emotion-sadness';
|
| 43 |
+
if(l.includes('anger')||l.includes('angry'))return 'emotion-anger';
|
| 44 |
+
if(l.includes('joy')||l.includes('happy'))return 'emotion-joy';
|
| 45 |
+
if(l.includes('anxi'))return 'emotion-anxiety';
|
| 46 |
+
if(l.includes('shame'))return 'emotion-shame';
|
| 47 |
+
if(l.includes('depress'))return 'emotion-depression';
|
| 48 |
+
return 'emotion-default';
|
| 49 |
+
}
|
| 50 |
+
function scoreColor(s,m=7){const r=s/m;return r>=.7?'#34d399':r>=.4?'#fbbf24':'#f87171';}
|
| 51 |
+
function deepCopy(o){return JSON.parse(JSON.stringify(o));}
|
| 52 |
+
|
| 53 |
+
// ===== Conversation parsing & highlights =====
|
| 54 |
+
function parseConv(s){
|
| 55 |
+
if(!s)return [];
|
| 56 |
+
const msgs=[];
|
| 57 |
+
s.split('\n').forEach(line=>{
|
| 58 |
+
const t=line.trim();
|
| 59 |
+
if(!t)return;
|
| 60 |
+
if(t.startsWith('speaker:')||t.startsWith('listener:')){
|
| 61 |
+
const isL=t.startsWith('listener:');
|
| 62 |
+
const c=t.replace(/^(speaker|listener):\s*/,'');
|
| 63 |
+
if(c)msgs.push({role:isL?'listener':'speaker',text:c});
|
| 64 |
+
} else if(msgs.length>0) msgs[msgs.length-1].text+=' '+t;
|
| 65 |
+
});
|
| 66 |
+
return msgs;
|
| 67 |
+
}
|
| 68 |
+
function applyHighlights(msgs,quotes){
|
| 69 |
+
return msgs.map(msg=>{
|
| 70 |
+
let text=msg.text;
|
| 71 |
+
quotes.forEach(({quote,cls})=>{
|
| 72 |
+
if(!quote||quote==='No evidence present')return;
|
| 73 |
+
const q=quote.replace(/^(listener|speaker):\s*/i,'').trim();
|
| 74 |
+
if(!q)return;
|
| 75 |
+
const i=text.indexOf(q);
|
| 76 |
+
if(i!==-1)text=text.slice(0,i)+`<mark class="evidence-hl ${cls}" data-quote="${escA(q)}">`+text.slice(i,i+q.length)+'</mark>'+text.slice(i+q.length);
|
| 77 |
+
});
|
| 78 |
+
return {...msg,htmlText:text};
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
function buildQuotes(anno){
|
| 82 |
+
const quotes=[];
|
| 83 |
+
(anno.distortions?.distortions||[]).forEach((d,i)=>{
|
| 84 |
+
if(d.evidence_quote&&d.evidence_quote!=='No evidence present')
|
| 85 |
+
quotes.push({quote:d.evidence_quote,cls:`dist-hl-${i%3}`,type:'dist',idx:i});
|
| 86 |
+
});
|
| 87 |
+
(anno.empathy?.empathy_dimensions||[]).forEach((dim,i)=>{
|
| 88 |
+
if(dim.evidence_quote&&dim.evidence_quote!=='No evidence present')
|
| 89 |
+
quotes.push({quote:dim.evidence_quote,cls:`emp-hl-${i%9}`,type:'emp',idx:i});
|
| 90 |
+
});
|
| 91 |
+
return quotes;
|
| 92 |
+
}
|
| 93 |
+
function scrollToEvidence(prefix,idx){
|
| 94 |
+
const m=document.querySelector(`.conversation-view mark.${prefix}-${idx}`);
|
| 95 |
+
if(!m)return;
|
| 96 |
+
document.querySelectorAll('mark.evidence-pulse').forEach(x=>x.classList.remove('evidence-pulse'));
|
| 97 |
+
m.scrollIntoView({behavior:'smooth',block:'center'});
|
| 98 |
+
m.classList.add('evidence-pulse');
|
| 99 |
+
setTimeout(()=>m.classList.remove('evidence-pulse'),2000);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ===== Render: Conversation =====
|
| 103 |
+
function renderConversation(row){
|
| 104 |
+
const anno=getEffectiveAnno(row);
|
| 105 |
+
const quotes=buildQuotes(anno);
|
| 106 |
+
const msgs=applyHighlights(parseConv(row['conversation_cut']),quotes);
|
| 107 |
+
const view=document.getElementById('conversationView');
|
| 108 |
+
view.innerHTML=msgs.map(m=>`<div class="message-bubble ${m.role}"><div class="message-label">${m.role==='listener'?'Listener':'Speaker'}</div><div class="message-text">${m.htmlText||escH(m.text)}</div></div>`).join('');
|
| 109 |
+
view.querySelectorAll('mark.evidence-hl').forEach(mk=>{
|
| 110 |
+
mk.addEventListener('mouseenter',()=>showTooltip(mk,anno));
|
| 111 |
+
mk.addEventListener('mouseleave',hideTooltip);
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
function renderConvHeader(row){
|
| 115 |
+
const anno=getEffectiveAnno(row);
|
| 116 |
+
const e=anno.emotion;
|
| 117 |
+
const en=e?.primary_emotion||row['emotion_type']||'—';
|
| 118 |
+
const ei=e?.intensity||'';
|
| 119 |
+
const intCls=ei.toLowerCase().includes('high')?'intensity-high':ei.toLowerCase().includes('medium')?'intensity-medium':'intensity-low';
|
| 120 |
+
document.getElementById('convMeta').innerHTML=`
|
| 121 |
+
<span class="meta-badge ${emotionCls(en)}">${en}</span>
|
| 122 |
+
${ei?`<span class="intensity-badge ${intCls}">${ei}</span>`:''}
|
| 123 |
+
${row.problem_type?`<span class="problem-badge">${row.problem_type}</span>`:''}
|
| 124 |
+
${row.experience_type?`<span class="experience-badge">${row.experience_type}</span>`:''}`;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// ===== Tooltip =====
|
| 128 |
+
function showTooltip(mk,anno){
|
| 129 |
+
const cls=Array.from(mk.classList).find(c=>c.startsWith('dist-hl-')||c.startsWith('emp-hl-'));
|
| 130 |
+
let html='';
|
| 131 |
+
if(cls?.startsWith('dist-hl-')){
|
| 132 |
+
const i=parseInt(cls.split('-').pop());
|
| 133 |
+
const d=(anno.distortions?.distortions||[])[i];
|
| 134 |
+
if(d){const c=DIST_COLORS[i%DIST_COLORS.length];html=`<div class="tooltip-title" style="color:${c}">⚠️ ${d.cognitive_distortion||'Distortion'}</div><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${d.reasoning||'—'}</div>`;}
|
| 135 |
+
} else if(cls?.startsWith('emp-hl-')){
|
| 136 |
+
const i=parseInt(cls.split('-').pop());
|
| 137 |
+
const dim=(anno.empathy?.empathy_dimensions||[])[i];
|
| 138 |
+
if(dim){const c=EMP_COLORS[i%EMP_COLORS.length],sc=scoreColor(dim.score||0);html=`<div class="tooltip-title" style="color:${c}">💡 ${dim.dimension_name}</div><div class="tooltip-label">Score</div><span class="tooltip-score" style="background:${sc}22;color:${sc};border:1px solid ${sc}44">${dim.score}/7</span><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${dim.reasoning||'—'}</div>`;}
|
| 139 |
+
}
|
| 140 |
+
if(!html)return;
|
| 141 |
+
const tip=document.getElementById('tooltip');
|
| 142 |
+
document.getElementById('tooltipContent').innerHTML=html;
|
| 143 |
+
tip.classList.add('visible');
|
| 144 |
+
const r=mk.getBoundingClientRect();
|
| 145 |
+
let top=r.bottom+8,left=r.left;
|
| 146 |
+
if(left+320>window.innerWidth)left=window.innerWidth-330;
|
| 147 |
+
if(top+200>window.innerHeight)top=r.top-200;
|
| 148 |
+
tip.style.top=top+'px';tip.style.left=left+'px';
|
| 149 |
+
}
|
| 150 |
+
function hideTooltip(){document.getElementById('tooltip').classList.remove('visible');}
|
| 151 |
+
|
| 152 |
+
// ===== Render: Emotion (View + Edit) =====
|
| 153 |
+
function renderEmotion(row){
|
| 154 |
+
const anno=getEffectiveAnno(row);
|
| 155 |
+
const e=anno.emotion;
|
| 156 |
+
const el=document.getElementById('emotionContent');
|
| 157 |
+
const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
|
| 158 |
+
const btn=document.getElementById('editEmotionBtn');
|
| 159 |
+
btn.classList.toggle('active',editMode.emotion);
|
| 160 |
+
btn.textContent=editMode.emotion?'✓ Done':'✏️ Edit';
|
| 161 |
+
|
| 162 |
+
if(editMode.emotion){
|
| 163 |
+
const d=editData.emotion;
|
| 164 |
+
el.innerHTML=`
|
| 165 |
+
<div class="edit-group"><div class="edit-label">Primary Emotion</div>
|
| 166 |
+
<select class="edit-field" id="ee-primary">
|
| 167 |
+
${EMOTION_OPTS.map(o=>`<option value="${o}"${d?.primary_emotion===o?' selected':''}>${o}</option>`).join('')}
|
| 168 |
+
<option value="${d?.primary_emotion||''}"${!EMOTION_OPTS.includes(d?.primary_emotion)?'selected':''} hidden>${d?.primary_emotion||'Custom'}</option>
|
| 169 |
+
</select></div>
|
| 170 |
+
<div class="edit-group"><div class="edit-label">Intensity</div>
|
| 171 |
+
<select class="edit-field" id="ee-intensity">
|
| 172 |
+
${INTENSITY_OPTS.map(o=>`<option value="${o}"${d?.intensity===o?' selected':''}>${o}</option>`).join('')}
|
| 173 |
+
</select></div>
|
| 174 |
+
<div class="edit-group"><div class="edit-label">Specific Term</div>
|
| 175 |
+
<input class="edit-field" id="ee-term" value="${escA(d?.specific_emotion_term||'')}"/></div>
|
| 176 |
+
<div class="edit-group"><div class="edit-label">Reasoning</div>
|
| 177 |
+
<textarea class="edit-field" id="ee-reason" rows="4">${d?.reasoning||''}</textarea></div>`;
|
| 178 |
+
['ee-primary','ee-intensity','ee-term','ee-reason'].forEach(id=>{
|
| 179 |
+
document.getElementById(id)?.addEventListener('input',()=>{
|
| 180 |
+
editData.emotion={
|
| 181 |
+
primary_emotion:document.getElementById('ee-primary').value,
|
| 182 |
+
intensity:document.getElementById('ee-intensity').value,
|
| 183 |
+
specific_emotion_term:document.getElementById('ee-term').value,
|
| 184 |
+
reasoning:document.getElementById('ee-reason').value
|
| 185 |
+
};
|
| 186 |
+
});
|
| 187 |
+
});
|
| 188 |
+
} else {
|
| 189 |
+
if(!e){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';return;}
|
| 190 |
+
const intCls=(e.intensity||'').toLowerCase().includes('high')?'intensity-high':(e.intensity||'').toLowerCase().includes('medium')?'intensity-medium':'intensity-low';
|
| 191 |
+
el.innerHTML=`
|
| 192 |
+
<div class="emotion-primary">
|
| 193 |
+
<span class="emotion-badge ${emotionCls(e.primary_emotion)}">${e.primary_emotion||'—'}</span>
|
| 194 |
+
<span class="intensity-badge ${intCls}">${e.intensity||'—'}</span>
|
| 195 |
+
${hasEdit?'<span class="edited-badge">Edited</span>':''}
|
| 196 |
+
</div>
|
| 197 |
+
${e.specific_emotion_term?`<p class="specific-term">Term: <span>${e.specific_emotion_term}</span></p>`:''}
|
| 198 |
+
${e.reasoning?`<p class="emotion-reasoning">${e.reasoning}</p>`:''}`;
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ===== Render: Distortions (View + Edit) =====
|
| 203 |
+
function renderDistortions(row){
|
| 204 |
+
const anno=getEffectiveAnno(row);
|
| 205 |
+
const el=document.getElementById('distortionsContent');
|
| 206 |
+
const dists=editMode.distortions ? editData.distortions : (anno.distortions?.distortions||[]);
|
| 207 |
+
const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
|
| 208 |
+
const btn=document.getElementById('editDistBtn');
|
| 209 |
+
btn.classList.toggle('active',editMode.distortions);
|
| 210 |
+
btn.textContent=editMode.distortions?'✓ Done':'✏️ Edit';
|
| 211 |
+
|
| 212 |
+
if(editMode.distortions){
|
| 213 |
+
if(!dists.length){
|
| 214 |
+
el.innerHTML=`<div class="no-distortions"><span>No distortions</span></div>`;
|
| 215 |
+
} else {
|
| 216 |
+
el.innerHTML=dists.map((d,i)=>{
|
| 217 |
+
const c=DIST_COLORS[i%DIST_COLORS.length];
|
| 218 |
+
return `<div class="distortion-item editing" data-dist-idx="${i}">
|
| 219 |
+
<div class="edit-row">
|
| 220 |
+
<span class="dist-color-dot" style="background:${c}"></span>
|
| 221 |
+
<select class="edit-field" data-d-field="name" data-d-idx="${i}" style="flex:1">
|
| 222 |
+
<option value="" disabled ${!d.cognitive_distortion?'selected':''}>Select Distortion...</option>
|
| 223 |
+
${DISTORTION_OPTS.map(o=>`<option value="${o}" ${d.cognitive_distortion===o?'selected':''}>${o}</option>`).join('')}
|
| 224 |
+
<option value="${escA(d.cognitive_distortion||'')}" ${!DISTORTION_OPTS.includes(d.cognitive_distortion||'')&&d.cognitive_distortion?'selected':''} hidden>${escA(d.cognitive_distortion||'Custom')}</option>
|
| 225 |
+
</select>
|
| 226 |
+
<button class="remove-item-btn" data-remove-dist="${i}">✕</button>
|
| 227 |
+
</div>
|
| 228 |
+
<div class="edit-label" style="margin-top:6px">Evidence Quote</div>
|
| 229 |
+
<div class="edit-row">
|
| 230 |
+
<input class="edit-field" data-d-field="quote" data-d-idx="${i}" value="${escA(d.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
|
| 231 |
+
<button class="select-evidence-btn" data-sel-card="distortions" data-sel-idx="${i}">📌 Select</button>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="edit-label" style="margin-top:6px">Reasoning</div>
|
| 234 |
+
<textarea class="edit-field" data-d-field="reason" data-d-idx="${i}" rows="2" placeholder="Reasoning...">${d.reasoning||''}</textarea>
|
| 235 |
+
</div>`;
|
| 236 |
+
}).join('');
|
| 237 |
+
}
|
| 238 |
+
el.innerHTML+=`<button class="add-item-btn" id="addDistBtn">+ Add Distortion</button>`;
|
| 239 |
+
// events
|
| 240 |
+
el.querySelectorAll('[data-d-field]').forEach(inp=>{
|
| 241 |
+
inp.addEventListener('input',()=>{
|
| 242 |
+
const idx=parseInt(inp.dataset.dIdx);
|
| 243 |
+
const f=inp.dataset.dField;
|
| 244 |
+
if(f==='name') editData.distortions[idx].cognitive_distortion=inp.value;
|
| 245 |
+
else if(f==='quote') editData.distortions[idx].evidence_quote=inp.value;
|
| 246 |
+
else if(f==='reason') editData.distortions[idx].reasoning=inp.value;
|
| 247 |
+
});
|
| 248 |
+
});
|
| 249 |
+
el.querySelectorAll('[data-remove-dist]').forEach(btn=>{
|
| 250 |
+
btn.addEventListener('click',()=>{
|
| 251 |
+
editData.distortions.splice(parseInt(btn.dataset.removeDist),1);
|
| 252 |
+
renderDistortions(row);
|
| 253 |
+
});
|
| 254 |
+
});
|
| 255 |
+
el.querySelectorAll('[data-sel-card]').forEach(btn=>{
|
| 256 |
+
btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
|
| 257 |
+
});
|
| 258 |
+
document.getElementById('addDistBtn')?.addEventListener('click',()=>{
|
| 259 |
+
editData.distortions.push({cognitive_distortion:'',evidence_quote:'',reasoning:''});
|
| 260 |
+
renderDistortions(row);
|
| 261 |
+
});
|
| 262 |
+
} else {
|
| 263 |
+
if(!dists.length){el.innerHTML=`<div class="no-distortions"><span>No distortions detected</span></div>`;return;}
|
| 264 |
+
el.innerHTML=dists.map((d,i)=>{
|
| 265 |
+
const c=DIST_COLORS[i%DIST_COLORS.length];
|
| 266 |
+
const q=(d.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
|
| 267 |
+
return `<div class="distortion-item" data-dist-idx="${i}">
|
| 268 |
+
<div class="distortion-name"><span class="dist-color-dot" style="background:${c}"></span>${d.cognitive_distortion||'—'} ${hasEdit&&i===0?'<span class="edited-badge">Edited</span>':''}</div>
|
| 269 |
+
${q?`<div class="distortion-quote" style="border-color:${c}">${q}</div>`:''}
|
| 270 |
+
${d.reasoning?`<div class="distortion-reasoning">${d.reasoning}</div>`:''}
|
| 271 |
+
</div>`;
|
| 272 |
+
}).join('');
|
| 273 |
+
el.querySelectorAll('.distortion-item').forEach(item=>{
|
| 274 |
+
item.addEventListener('click',()=>scrollToEvidence('dist-hl',parseInt(item.dataset.distIdx)%3));
|
| 275 |
+
});
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// ===== Render: Empathy (View + Edit) =====
|
| 280 |
+
function renderEmpathy(row){
|
| 281 |
+
const anno=getEffectiveAnno(row);
|
| 282 |
+
const el=document.getElementById('empathyContent');
|
| 283 |
+
const scoreEl=document.getElementById('overallScore');
|
| 284 |
+
const dims=editMode.empathy ? editData.empathy : (anno.empathy?.empathy_dimensions||[]);
|
| 285 |
+
const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
|
| 286 |
+
const btn=document.getElementById('editEmpBtn');
|
| 287 |
+
btn.classList.toggle('active',editMode.empathy);
|
| 288 |
+
btn.textContent=editMode.empathy?'✓ Done':'✏️ Edit';
|
| 289 |
+
|
| 290 |
+
if(!anno.empathy&&!editMode.empathy){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';scoreEl.textContent='';return;}
|
| 291 |
+
const total=dims.reduce((s,d)=>s+(d.score||0),0);
|
| 292 |
+
scoreEl.textContent=dims.length?`${total} / ${dims.length*7}`:'';
|
| 293 |
+
|
| 294 |
+
if(editMode.empathy){
|
| 295 |
+
el.innerHTML=dims.map((dim,i)=>{
|
| 296 |
+
const c=EMP_COLORS[i%EMP_COLORS.length];
|
| 297 |
+
const sc=dim.score||0;
|
| 298 |
+
return `<div class="empathy-item editing" data-emp-idx="${i}">
|
| 299 |
+
<div class="edit-row">
|
| 300 |
+
<span class="emp-color-dot" style="background:${c}"></span>
|
| 301 |
+
<input class="edit-field" data-e-field="name" data-e-idx="${i}" value="${escA(dim.dimension_name||'')}" placeholder="Dimension name" style="flex:1"/>
|
| 302 |
+
<button class="remove-item-btn" data-remove-emp="${i}">✕</button>
|
| 303 |
+
</div>
|
| 304 |
+
<div class="edit-label" style="margin-top:6px">Score (0–7)</div>
|
| 305 |
+
<div class="edit-row">
|
| 306 |
+
<input type="range" class="score-slider" data-e-field="score" data-e-idx="${i}" min="0" max="7" value="${sc}"/>
|
| 307 |
+
<span class="score-display" id="score-disp-${i}" style="color:${scoreColor(sc)}">${sc}</span>
|
| 308 |
+
</div>
|
| 309 |
+
<div class="edit-label" style="margin-top:6px">Evidence Quote</div>
|
| 310 |
+
<div class="edit-row">
|
| 311 |
+
<input class="edit-field" data-e-field="quote" data-e-idx="${i}" value="${escA(dim.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
|
| 312 |
+
<button class="select-evidence-btn" data-sel-card="empathy" data-sel-idx="${i}">📌 Select</button>
|
| 313 |
+
</div>
|
| 314 |
+
<div class="edit-label" style="margin-top:6px">Reasoning</div>
|
| 315 |
+
<textarea class="edit-field" data-e-field="reason" data-e-idx="${i}" rows="2">${dim.reasoning||''}</textarea>
|
| 316 |
+
</div>`;
|
| 317 |
+
}).join('');
|
| 318 |
+
el.innerHTML+=`<button class="add-item-btn" id="addEmpBtn">+ Add Dimension</button>`;
|
| 319 |
+
// events
|
| 320 |
+
el.querySelectorAll('[data-e-field]').forEach(inp=>{
|
| 321 |
+
inp.addEventListener('input',()=>{
|
| 322 |
+
const idx=parseInt(inp.dataset.eIdx);
|
| 323 |
+
const f=inp.dataset.eField;
|
| 324 |
+
if(f==='name') editData.empathy[idx].dimension_name=inp.value;
|
| 325 |
+
else if(f==='score'){
|
| 326 |
+
const v=parseInt(inp.value);
|
| 327 |
+
editData.empathy[idx].score=v;
|
| 328 |
+
const disp=document.getElementById(`score-disp-${idx}`);
|
| 329 |
+
if(disp){disp.textContent=v;disp.style.color=scoreColor(v);}
|
| 330 |
+
}
|
| 331 |
+
else if(f==='quote') editData.empathy[idx].evidence_quote=inp.value;
|
| 332 |
+
else if(f==='reason') editData.empathy[idx].reasoning=inp.value;
|
| 333 |
+
});
|
| 334 |
+
});
|
| 335 |
+
el.querySelectorAll('[data-remove-emp]').forEach(btn=>{
|
| 336 |
+
btn.addEventListener('click',()=>{editData.empathy.splice(parseInt(btn.dataset.removeEmp),1);renderEmpathy(row);});
|
| 337 |
+
});
|
| 338 |
+
el.querySelectorAll('[data-sel-card]').forEach(btn=>{
|
| 339 |
+
btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
|
| 340 |
+
});
|
| 341 |
+
document.getElementById('addEmpBtn')?.addEventListener('click',()=>{
|
| 342 |
+
editData.empathy.push({dimension_name:'',score:0,evidence_quote:'',reasoning:''});
|
| 343 |
+
renderEmpathy(row);
|
| 344 |
+
});
|
| 345 |
+
} else {
|
| 346 |
+
el.innerHTML=dims.map((dim,i)=>{
|
| 347 |
+
const c=EMP_COLORS[i%EMP_COLORS.length];
|
| 348 |
+
const sc=dim.score||0;
|
| 349 |
+
const pct=Math.min(100,(sc/7)*100);
|
| 350 |
+
const sC=scoreColor(sc);
|
| 351 |
+
const q=(dim.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
|
| 352 |
+
return `<div class="empathy-item" data-emp-idx="${i}">
|
| 353 |
+
<div class="empathy-item-header">
|
| 354 |
+
<span class="emp-color-dot" style="background:${c}"></span>
|
| 355 |
+
<span class="empathy-name">${dim.dimension_name||'—'}</span>
|
| 356 |
+
<div class="score-bar-container">
|
| 357 |
+
<div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${sC}"></div></div>
|
| 358 |
+
<span class="score-value" style="color:${sC}">${sc}</span>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
${q&&q!=='No evidence present'?`<div class="empathy-quote" style="border-color:${c}" title="${escA(q)}">"${q}"</div>`:''}
|
| 362 |
+
${dim.reasoning?`<div class="empathy-reasoning">${dim.reasoning}</div>`:''}
|
| 363 |
+
</div>`;
|
| 364 |
+
}).join('');
|
| 365 |
+
el.querySelectorAll('.empathy-item').forEach(item=>{
|
| 366 |
+
item.addEventListener('click',()=>{
|
| 367 |
+
item.classList.toggle('active');
|
| 368 |
+
scrollToEvidence('emp-hl',parseInt(item.dataset.empIdx)%9);
|
| 369 |
+
});
|
| 370 |
+
});
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// ===== Evidence Selection Mode =====
|
| 375 |
+
function startSelectingEvidence(card, itemIdx, triggerBtn){
|
| 376 |
+
selectingEvidence={card, itemIdx};
|
| 377 |
+
const panel=document.querySelector('.conversation-panel');
|
| 378 |
+
panel.classList.add('selecting-mode');
|
| 379 |
+
const hint=document.getElementById('selectionHint');
|
| 380 |
+
hint.classList.add('visible');
|
| 381 |
+
// Mark button as active
|
| 382 |
+
document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting'));
|
| 383 |
+
triggerBtn?.classList.add('selecting');
|
| 384 |
+
}
|
| 385 |
+
function stopSelectingEvidence(){
|
| 386 |
+
selectingEvidence=null;
|
| 387 |
+
document.querySelector('.conversation-panel')?.classList.remove('selecting-mode');
|
| 388 |
+
const hint=document.getElementById('selectionHint');
|
| 389 |
+
hint?.classList.remove('visible');
|
| 390 |
+
document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting'));
|
| 391 |
+
}
|
| 392 |
+
function captureSelection(){
|
| 393 |
+
if(!selectingEvidence)return;
|
| 394 |
+
const sel=window.getSelection();
|
| 395 |
+
if(!sel||sel.rangeCount===0||sel.isCollapsed)return;
|
| 396 |
+
const text=sel.toString().trim();
|
| 397 |
+
if(!text)return;
|
| 398 |
+
const {card,itemIdx}=selectingEvidence;
|
| 399 |
+
// Set the value in editData
|
| 400 |
+
if(card==='distortions'&&editData.distortions[itemIdx]!==undefined){
|
| 401 |
+
editData.distortions[itemIdx].evidence_quote=text;
|
| 402 |
+
} else if(card==='empathy'&&editData.empathy[itemIdx]!==undefined){
|
| 403 |
+
editData.empathy[itemIdx].evidence_quote=text;
|
| 404 |
+
}
|
| 405 |
+
sel.removeAllRanges();
|
| 406 |
+
stopSelectingEvidence();
|
| 407 |
+
// Re-render the card to update input field
|
| 408 |
+
const row=filteredData[currentIndex];
|
| 409 |
+
if(card==='distortions') renderDistortions(row);
|
| 410 |
+
else if(card==='empathy') renderEmpathy(row);
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
// ===== Save Annotations =====
|
| 414 |
+
async function saveAnnotations(){
|
| 415 |
+
const row=filteredData[currentIndex];
|
| 416 |
+
const anno=getEffectiveAnno(row);
|
| 417 |
+
// Merge current editData into the annotation structure
|
| 418 |
+
const toSave={
|
| 419 |
+
emotion: editData.emotion || anno.emotion,
|
| 420 |
+
distortions: anno.distortions
|
| 421 |
+
? {...anno.distortions, distortions: editData.distortions||anno.distortions?.distortions||[]}
|
| 422 |
+
: {distortions: editData.distortions||[]},
|
| 423 |
+
empathy: anno.empathy
|
| 424 |
+
? {...anno.empathy, empathy_dimensions: editData.empathy||anno.empathy?.empathy_dimensions||[]}
|
| 425 |
+
: {empathy_dimensions: editData.empathy||[]}
|
| 426 |
+
};
|
| 427 |
+
try {
|
| 428 |
+
const res=await fetch('/api/reviews/annotations',{
|
| 429 |
+
method:'PATCH',
|
| 430 |
+
headers:{'Content-Type':'application/json'},
|
| 431 |
+
body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,annotations:toSave})
|
| 432 |
+
});
|
| 433 |
+
const data=await res.json();
|
| 434 |
+
if(res.ok){
|
| 435 |
+
reviewsMap[currentIndex]=data;
|
| 436 |
+
return true;
|
| 437 |
+
}
|
| 438 |
+
} catch(e){}
|
| 439 |
+
return false;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// ===== Toggle Edit Mode =====
|
| 443 |
+
function toggleEdit(card){
|
| 444 |
+
const row=filteredData[currentIndex];
|
| 445 |
+
const anno=getEffectiveAnno(row);
|
| 446 |
+
if(editMode[card]){
|
| 447 |
+
// Exiting edit mode — auto-save
|
| 448 |
+
editMode[card]=false;
|
| 449 |
+
stopSelectingEvidence();
|
| 450 |
+
saveAnnotations().then(ok=>{
|
| 451 |
+
if(ok){
|
| 452 |
+
// Re-render all to show updated highlights
|
| 453 |
+
renderAll();
|
| 454 |
+
}
|
| 455 |
+
});
|
| 456 |
+
} else {
|
| 457 |
+
// Entering edit mode — init editData from effective annotation
|
| 458 |
+
if(card==='emotion') editData.emotion=deepCopy(anno.emotion||{primary_emotion:'',intensity:'',specific_emotion_term:'',reasoning:''});
|
| 459 |
+
else if(card==='distortions') editData.distortions=deepCopy(anno.distortions?.distortions||[]);
|
| 460 |
+
else if(card==='empathy') editData.empathy=deepCopy(anno.empathy?.empathy_dimensions||[]);
|
| 461 |
+
editMode[card]=true;
|
| 462 |
+
if(card==='emotion') renderEmotion(row);
|
| 463 |
+
else if(card==='distortions') renderDistortions(row);
|
| 464 |
+
else if(card==='empathy') renderEmpathy(row);
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// ===== Review State =====
|
| 469 |
+
function renderReviewState(){
|
| 470 |
+
const review=reviewsMap[currentIndex];
|
| 471 |
+
const badge=document.getElementById('reviewStatusBadge');
|
| 472 |
+
const acceptBtn=document.getElementById('acceptBtn');
|
| 473 |
+
const rejectBtn=document.getElementById('rejectBtn');
|
| 474 |
+
const comment=document.getElementById('reviewComment');
|
| 475 |
+
const submitBtn=document.getElementById('submitReviewBtn');
|
| 476 |
+
acceptBtn.classList.remove('active');
|
| 477 |
+
rejectBtn.classList.remove('active');
|
| 478 |
+
currentReviewStatus=null;
|
| 479 |
+
if(review&&review.status&&review.status!=='reject'||(review&&review.status==='accept')){
|
| 480 |
+
// Has a real review decision
|
| 481 |
+
}
|
| 482 |
+
if(review){
|
| 483 |
+
badge.style.display='inline-block';
|
| 484 |
+
badge.textContent=review.status==='accept'?'✓ Accepted':'✗ Rejected';
|
| 485 |
+
badge.className='review-status-badge '+(review.status==='accept'?'review-status-accept':'review-status-reject');
|
| 486 |
+
currentReviewStatus=review.status;
|
| 487 |
+
if(review.status==='accept') acceptBtn.classList.add('active');
|
| 488 |
+
else rejectBtn.classList.add('active');
|
| 489 |
+
comment.value=review.comment||'';
|
| 490 |
+
submitBtn.textContent='Update Review';
|
| 491 |
+
} else {
|
| 492 |
+
badge.style.display='none';
|
| 493 |
+
comment.value='';
|
| 494 |
+
submitBtn.textContent='Submit Review';
|
| 495 |
+
}
|
| 496 |
+
submitBtn.disabled=false;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// ===== Sample List =====
|
| 500 |
+
function renderSampleList(){
|
| 501 |
+
const list=document.getElementById('sampleList');
|
| 502 |
+
list.innerHTML='';
|
| 503 |
+
filteredData.forEach((row,i)=>{
|
| 504 |
+
const anno=parseAnno(row);
|
| 505 |
+
const en=anno.emotion?.primary_emotion||row.emotion_type||'—';
|
| 506 |
+
const review=reviewsMap[i];
|
| 507 |
+
const dotCls=review?(review.status==='accept'?'dot-accept':'dot-reject'):'dot-pending';
|
| 508 |
+
const hasEdit=!!(review?.annotations_edit);
|
| 509 |
+
const item=document.createElement('div');
|
| 510 |
+
item.className='sample-item'+(i===currentIndex?' active':'');
|
| 511 |
+
item.dataset.idx=i;
|
| 512 |
+
item.innerHTML=`<div class="sample-item-idx">#${i+1} <span class="sample-review-dot ${dotCls}"></span>${hasEdit?'<span style="color:#fbbf24;font-size:.6rem">✏</span>':''}</div>
|
| 513 |
+
<div class="sample-item-emotion" style="color:var(--emotion-color)">${en}</div>
|
| 514 |
+
<div class="sample-item-problem">${row.problem_type||''}</div>`;
|
| 515 |
+
item.addEventListener('click',()=>{currentIndex=i;renderAll();});
|
| 516 |
+
list.appendChild(item);
|
| 517 |
+
});
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
// ===== Navigation =====
|
| 521 |
+
function updateNav(){
|
| 522 |
+
document.getElementById('prevBtn').disabled=currentIndex<=0;
|
| 523 |
+
document.getElementById('nextBtn').disabled=currentIndex>=filteredData.length-1;
|
| 524 |
+
document.getElementById('currentIdx').textContent=currentIndex+1;
|
| 525 |
+
document.getElementById('totalSamples').textContent=filteredData.length;
|
| 526 |
+
document.querySelectorAll('.sample-item').forEach(it=>it.classList.toggle('active',parseInt(it.dataset.idx)===currentIndex));
|
| 527 |
+
document.querySelector('.sample-item.active')?.scrollIntoView({block:'nearest'});
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// ===== Render All =====
|
| 531 |
+
function renderAll(){
|
| 532 |
+
if(!filteredData.length)return;
|
| 533 |
+
// Exit edit modes on navigation
|
| 534 |
+
editMode={emotion:false,distortions:false,empathy:false};
|
| 535 |
+
stopSelectingEvidence();
|
| 536 |
+
const row=filteredData[currentIndex];
|
| 537 |
+
renderConvHeader(row);
|
| 538 |
+
renderConversation(row);
|
| 539 |
+
renderEmotion(row);
|
| 540 |
+
renderDistortions(row);
|
| 541 |
+
renderEmpathy(row);
|
| 542 |
+
renderReviewState();
|
| 543 |
+
updateNav();
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
// ===== Filter =====
|
| 547 |
+
function applyFilter(q){
|
| 548 |
+
q=q.toLowerCase().trim();
|
| 549 |
+
filteredData=q?allData.filter(row=>{
|
| 550 |
+
const a=parseAnno(row);
|
| 551 |
+
return (a.emotion?.primary_emotion||row.emotion_type||'').toLowerCase().includes(q)||
|
| 552 |
+
(row.problem_type||'').toLowerCase().includes(q);
|
| 553 |
+
}):[...allData];
|
| 554 |
+
currentIndex=0;renderSampleList();renderAll();
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
// ===== Submit Review =====
|
| 558 |
+
async function submitReview(){
|
| 559 |
+
if(!currentReviewStatus){alert('Please select Accept or Reject');return;}
|
| 560 |
+
const comment=document.getElementById('reviewComment').value.trim();
|
| 561 |
+
const btn=document.getElementById('submitReviewBtn');
|
| 562 |
+
btn.disabled=true;btn.textContent='Submitting...';
|
| 563 |
+
try{
|
| 564 |
+
const res=await fetch('/api/reviews',{
|
| 565 |
+
method:'POST',headers:{'Content-Type':'application/json'},
|
| 566 |
+
body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,status:currentReviewStatus,comment})
|
| 567 |
+
});
|
| 568 |
+
const data=await res.json();
|
| 569 |
+
if(res.ok){
|
| 570 |
+
reviewsMap[currentIndex]=data;
|
| 571 |
+
renderReviewState();renderSampleList();
|
| 572 |
+
btn.textContent='✓ Saved!';
|
| 573 |
+
setTimeout(()=>{
|
| 574 |
+
btn.textContent='Update Review';btn.disabled=false;
|
| 575 |
+
const next=filteredData.findIndex((_,i)=>i>currentIndex&&!reviewsMap[i]);
|
| 576 |
+
if(next!==-1){currentIndex=next;renderAll();}
|
| 577 |
+
},800);
|
| 578 |
+
} else {alert(data.error||'Failed');btn.textContent='Submit Review';btn.disabled=false;}
|
| 579 |
+
} catch(e){alert('Network error');btn.textContent='Submit Review';btn.disabled=false;}
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
// ===== Load Data =====
|
| 583 |
+
async function loadAssignmentData(){
|
| 584 |
+
try{
|
| 585 |
+
let fileId,filename;
|
| 586 |
+
const r1=await fetch('/api/my-assignments');
|
| 587 |
+
const myAssign=await r1.json();
|
| 588 |
+
let a=myAssign.find(x=>x.id===assignmentId);
|
| 589 |
+
if(!a){
|
| 590 |
+
const r2=await fetch('/api/assignments');
|
| 591 |
+
if(r2.ok){const all=await r2.json();a=all.find(x=>x.id===assignmentId);}
|
| 592 |
+
}
|
| 593 |
+
if(!a){document.getElementById('reviewSubtitle').textContent='Assignment not found';return;}
|
| 594 |
+
fileId=a.file_id;filename=a.filename;
|
| 595 |
+
const r3=await fetch(`/api/files/${fileId}/data`);
|
| 596 |
+
const fd=await r3.json();
|
| 597 |
+
allData=fd.samples||[];filteredData=[...allData];
|
| 598 |
+
const r4=await fetch(`/api/assignments/${assignmentId}/reviews`);
|
| 599 |
+
reviewsMap=await r4.json();
|
| 600 |
+
document.getElementById('reviewTitle').textContent='Review';
|
| 601 |
+
document.getElementById('reviewSubtitle').textContent=filename||'Annotation Review';
|
| 602 |
+
currentIndex=0;renderSampleList();renderAll();
|
| 603 |
+
} catch(err){console.error(err);document.getElementById('reviewSubtitle').textContent='Failed to load';}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// ===== DOMContentLoaded =====
|
| 607 |
+
document.addEventListener('DOMContentLoaded',async()=>{
|
| 608 |
+
const parts=window.location.pathname.split('/');
|
| 609 |
+
assignmentId=parseInt(parts[parts.length-1]);
|
| 610 |
+
if(isNaN(assignmentId))return;
|
| 611 |
+
try{const m=await fetch('/api/me').then(r=>r.json());document.getElementById('userBadge').textContent=m.username;}catch(e){}
|
| 612 |
+
document.getElementById('logoutBtn').addEventListener('click',async()=>{await fetch('/api/logout',{method:'POST'});window.location.href='/login';});
|
| 613 |
+
document.getElementById('prevBtn').addEventListener('click',()=>{if(currentIndex>0){currentIndex--;renderAll();}});
|
| 614 |
+
document.getElementById('nextBtn').addEventListener('click',()=>{if(currentIndex<filteredData.length-1){currentIndex++;renderAll();}});
|
| 615 |
+
document.getElementById('searchInput').addEventListener('input',e=>applyFilter(e.target.value));
|
| 616 |
+
document.getElementById('acceptBtn').addEventListener('click',()=>{currentReviewStatus='accept';document.getElementById('acceptBtn').classList.add('active');document.getElementById('rejectBtn').classList.remove('active');});
|
| 617 |
+
document.getElementById('rejectBtn').addEventListener('click',()=>{currentReviewStatus='reject';document.getElementById('rejectBtn').classList.add('active');document.getElementById('acceptBtn').classList.remove('active');});
|
| 618 |
+
document.getElementById('submitReviewBtn').addEventListener('click',submitReview);
|
| 619 |
+
// Edit toggle buttons
|
| 620 |
+
['editEmotionBtn','editDistBtn','editEmpBtn'].forEach(id=>{
|
| 621 |
+
document.getElementById(id)?.addEventListener('click',()=>{
|
| 622 |
+
const card={'editEmotionBtn':'emotion','editDistBtn':'distortions','editEmpBtn':'empathy'}[id];
|
| 623 |
+
toggleEdit(card);
|
| 624 |
+
});
|
| 625 |
+
});
|
| 626 |
+
// Capture text selection from conversation
|
| 627 |
+
document.getElementById('conversationView').addEventListener('mouseup',()=>{
|
| 628 |
+
if(selectingEvidence) captureSelection();
|
| 629 |
+
});
|
| 630 |
+
// Escape key cancels selection
|
| 631 |
+
document.addEventListener('keydown',e=>{
|
| 632 |
+
if(e.key==='Escape'){stopSelectingEvidence();return;}
|
| 633 |
+
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
|
| 634 |
+
if(e.key==='ArrowLeft'&¤tIndex>0){currentIndex--;renderAll();}
|
| 635 |
+
if(e.key==='ArrowRight'&¤tIndex<filteredData.length-1){currentIndex++;renderAll();}
|
| 636 |
+
});
|
| 637 |
+
await loadAssignmentData();
|
| 638 |
+
});
|
| 639 |
+
|
| 640 |
+
// ===== Export =====
|
| 641 |
+
function doExport(format){
|
| 642 |
+
if(!assignmentId)return;
|
| 643 |
+
const url=`/api/assignments/${assignmentId}/export?format=${format}`;
|
| 644 |
+
if(format==='csv'){
|
| 645 |
+
// Direct download
|
| 646 |
+
const a=document.createElement('a');
|
| 647 |
+
a.href=url;a.download='';a.click();
|
| 648 |
+
} else {
|
| 649 |
+
// JSON: fetch and download as file
|
| 650 |
+
fetch(url).then(r=>r.json()).then(data=>{
|
| 651 |
+
const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
|
| 652 |
+
const a=document.createElement('a');
|
| 653 |
+
a.href=URL.createObjectURL(blob);
|
| 654 |
+
a.download=`export_assignment_${assignmentId}.json`;
|
| 655 |
+
a.click();URL.revokeObjectURL(a.href);
|
| 656 |
+
}).catch(e=>alert('Export failed'));
|
| 657 |
+
}
|
| 658 |
+
document.getElementById('exportMenu').style.display='none';
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
// Wire up export buttons after DOM ready
|
| 662 |
+
document.addEventListener('DOMContentLoaded',()=>{
|
| 663 |
+
const exportBtn=document.getElementById('exportBtn');
|
| 664 |
+
const exportMenu=document.getElementById('exportMenu');
|
| 665 |
+
if(exportBtn&&exportMenu){
|
| 666 |
+
exportBtn.addEventListener('click',(e)=>{
|
| 667 |
+
e.stopPropagation();
|
| 668 |
+
exportMenu.style.display=exportMenu.style.display==='none'?'block':'none';
|
| 669 |
+
});
|
| 670 |
+
document.getElementById('exportJSON')?.addEventListener('click',()=>doExport('json'));
|
| 671 |
+
document.getElementById('exportCSV')?.addEventListener('click',()=>doExport('csv'));
|
| 672 |
+
// Close menu on outside click
|
| 673 |
+
document.addEventListener('click',()=>{exportMenu.style.display='none';});
|
| 674 |
+
}
|
| 675 |
+
});
|
static/style.css
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== CSS Variables ===== */
|
| 2 |
+
:root {
|
| 3 |
+
--bg-primary: #0d1117;
|
| 4 |
+
--bg-secondary: #161b22;
|
| 5 |
+
--bg-card: #1c2128;
|
| 6 |
+
--bg-hover: #21262d;
|
| 7 |
+
--border: #30363d;
|
| 8 |
+
--border-light: #21262d;
|
| 9 |
+
--text-primary: #e6edf3;
|
| 10 |
+
--text-secondary: #8b949e;
|
| 11 |
+
--text-muted: #6e7681;
|
| 12 |
+
--accent: #58a6ff;
|
| 13 |
+
--accent-hover: #79c0ff;
|
| 14 |
+
|
| 15 |
+
--emotion-color: #a78bfa;
|
| 16 |
+
--emotion-bg: rgba(167, 139, 250, 0.12);
|
| 17 |
+
--distortion-color: #fb923c;
|
| 18 |
+
--distortion-bg: rgba(251, 146, 60, 0.12);
|
| 19 |
+
--empathy-color: #34d399;
|
| 20 |
+
--empathy-bg: rgba(52, 211, 153, 0.12);
|
| 21 |
+
--highlight-color: #fbbf24;
|
| 22 |
+
|
| 23 |
+
--speaker-bg: #1a2332;
|
| 24 |
+
--speaker-border: #1d4ed8;
|
| 25 |
+
--listener-bg: #1a2b1f;
|
| 26 |
+
--listener-border: #16a34a;
|
| 27 |
+
|
| 28 |
+
/* Highlight colors for evidence quotes */
|
| 29 |
+
--hl-distortion-1: rgba(251, 146, 60, 0.25);
|
| 30 |
+
--hl-distortion-2: rgba(248, 113, 113, 0.25);
|
| 31 |
+
--hl-distortion-3: rgba(251, 191, 36, 0.25);
|
| 32 |
+
|
| 33 |
+
--hl-empathy-concern: rgba(96, 165, 250, 0.22);
|
| 34 |
+
--hl-empathy-expressiveness: rgba(167, 139, 250, 0.22);
|
| 35 |
+
--hl-empathy-resonate: rgba(52, 211, 153, 0.22);
|
| 36 |
+
--hl-empathy-warmth: rgba(251, 191, 36, 0.22);
|
| 37 |
+
--hl-empathy-attuned: rgba(251, 146, 60, 0.22);
|
| 38 |
+
--hl-empathy-cognitive: rgba(248, 113, 113, 0.22);
|
| 39 |
+
--hl-empathy-feelings: rgba(129, 140, 248, 0.22);
|
| 40 |
+
--hl-empathy-acceptance: rgba(45, 212, 191, 0.22);
|
| 41 |
+
--hl-empathy-responsiveness: rgba(232, 121, 249, 0.22);
|
| 42 |
+
|
| 43 |
+
--radius-sm: 6px;
|
| 44 |
+
--radius: 10px;
|
| 45 |
+
--radius-lg: 14px;
|
| 46 |
+
--shadow: 0 4px 16px rgba(0,0,0,0.4);
|
| 47 |
+
--shadow-card: 0 2px 8px rgba(0,0,0,0.3);
|
| 48 |
+
|
| 49 |
+
--header-h: 60px;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* ===== Reset & Base ===== */
|
| 53 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 54 |
+
html { font-size: 16px; scroll-behavior: smooth; }
|
| 55 |
+
body {
|
| 56 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 57 |
+
background: var(--bg-primary);
|
| 58 |
+
color: var(--text-primary);
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
overflow: hidden;
|
| 61 |
+
}
|
| 62 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 63 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 64 |
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 65 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
| 66 |
+
|
| 67 |
+
/* ===== Header ===== */
|
| 68 |
+
.header {
|
| 69 |
+
position: fixed;
|
| 70 |
+
top: 0; left: 0; right: 0;
|
| 71 |
+
height: var(--header-h);
|
| 72 |
+
background: rgba(13, 17, 23, 0.92);
|
| 73 |
+
border-bottom: 1px solid var(--border);
|
| 74 |
+
backdrop-filter: blur(12px);
|
| 75 |
+
z-index: 100;
|
| 76 |
+
}
|
| 77 |
+
.header-inner {
|
| 78 |
+
max-width: 100%;
|
| 79 |
+
height: 100%;
|
| 80 |
+
padding: 0 20px;
|
| 81 |
+
display: flex;
|
| 82 |
+
align-items: center;
|
| 83 |
+
justify-content: space-between;
|
| 84 |
+
gap: 16px;
|
| 85 |
+
}
|
| 86 |
+
.header-logo {
|
| 87 |
+
display: flex;
|
| 88 |
+
align-items: center;
|
| 89 |
+
gap: 10px;
|
| 90 |
+
}
|
| 91 |
+
.logo-icon {
|
| 92 |
+
width: 36px; height: 36px;
|
| 93 |
+
background: linear-gradient(135deg, var(--accent), var(--empathy-color));
|
| 94 |
+
border-radius: var(--radius-sm);
|
| 95 |
+
display: flex; align-items: center; justify-content: center;
|
| 96 |
+
color: #fff;
|
| 97 |
+
flex-shrink: 0;
|
| 98 |
+
}
|
| 99 |
+
.header-logo h1 { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
|
| 100 |
+
.header-logo p { font-size: 0.75rem; color: var(--text-muted); }
|
| 101 |
+
.header-controls { display: flex; align-items: center; gap: 12px; }
|
| 102 |
+
|
| 103 |
+
.file-upload-area {
|
| 104 |
+
display: flex; align-items: center; gap: 8px;
|
| 105 |
+
padding: 7px 14px;
|
| 106 |
+
background: var(--bg-card);
|
| 107 |
+
border: 1px solid var(--border);
|
| 108 |
+
border-radius: var(--radius-sm);
|
| 109 |
+
cursor: pointer;
|
| 110 |
+
font-size: 0.85rem;
|
| 111 |
+
color: var(--text-secondary);
|
| 112 |
+
transition: all 0.2s;
|
| 113 |
+
position: relative;
|
| 114 |
+
overflow: hidden;
|
| 115 |
+
}
|
| 116 |
+
.file-upload-area:hover { border-color: var(--accent); color: var(--accent); background: var(--bg-hover); }
|
| 117 |
+
.file-upload-area input[type="file"] {
|
| 118 |
+
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
|
| 119 |
+
}
|
| 120 |
+
.sample-counter {
|
| 121 |
+
padding: 6px 14px;
|
| 122 |
+
background: var(--bg-card);
|
| 123 |
+
border: 1px solid var(--border);
|
| 124 |
+
border-radius: var(--radius-sm);
|
| 125 |
+
font-size: 0.85rem;
|
| 126 |
+
color: var(--text-secondary);
|
| 127 |
+
font-variant-numeric: tabular-nums;
|
| 128 |
+
}
|
| 129 |
+
.sample-counter #currentIdx { color: var(--accent); font-weight: 600; }
|
| 130 |
+
|
| 131 |
+
/* ===== Welcome Screen ===== */
|
| 132 |
+
.welcome-screen {
|
| 133 |
+
position: fixed;
|
| 134 |
+
inset: 0;
|
| 135 |
+
display: flex;
|
| 136 |
+
align-items: center;
|
| 137 |
+
justify-content: center;
|
| 138 |
+
background: var(--bg-primary);
|
| 139 |
+
z-index: 50;
|
| 140 |
+
padding: 20px;
|
| 141 |
+
}
|
| 142 |
+
.welcome-card {
|
| 143 |
+
max-width: 480px;
|
| 144 |
+
width: 100%;
|
| 145 |
+
background: var(--bg-card);
|
| 146 |
+
border: 1px solid var(--border);
|
| 147 |
+
border-radius: var(--radius-lg);
|
| 148 |
+
padding: 40px;
|
| 149 |
+
text-align: center;
|
| 150 |
+
box-shadow: var(--shadow);
|
| 151 |
+
}
|
| 152 |
+
.welcome-icon {
|
| 153 |
+
width: 80px; height: 80px;
|
| 154 |
+
background: linear-gradient(135deg, rgba(88,166,255,0.15), rgba(52,211,153,0.15));
|
| 155 |
+
border: 1px solid var(--border);
|
| 156 |
+
border-radius: 20px;
|
| 157 |
+
display: flex; align-items: center; justify-content: center;
|
| 158 |
+
margin: 0 auto 24px;
|
| 159 |
+
color: var(--accent);
|
| 160 |
+
}
|
| 161 |
+
.welcome-card h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 10px; }
|
| 162 |
+
.welcome-card p { color: var(--text-secondary); line-height: 1.6; margin-bottom: 28px; }
|
| 163 |
+
.welcome-features {
|
| 164 |
+
display: flex; flex-direction: column; gap: 10px;
|
| 165 |
+
margin-bottom: 32px;
|
| 166 |
+
text-align: left;
|
| 167 |
+
}
|
| 168 |
+
.feature-item {
|
| 169 |
+
display: flex; align-items: center; gap: 10px;
|
| 170 |
+
font-size: 0.875rem; color: var(--text-secondary);
|
| 171 |
+
}
|
| 172 |
+
.feature-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
| 173 |
+
.welcome-upload-btn {
|
| 174 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 175 |
+
padding: 12px 28px;
|
| 176 |
+
background: linear-gradient(135deg, var(--accent), #388bfd);
|
| 177 |
+
color: #fff;
|
| 178 |
+
border-radius: var(--radius-sm);
|
| 179 |
+
font-weight: 600;
|
| 180 |
+
cursor: pointer;
|
| 181 |
+
transition: opacity 0.2s, transform 0.2s;
|
| 182 |
+
font-size: 0.9rem;
|
| 183 |
+
}
|
| 184 |
+
.welcome-upload-btn:hover { opacity: 0.9; transform: translateY(-1px); }
|
| 185 |
+
|
| 186 |
+
/* ===== Main Layout ===== */
|
| 187 |
+
.main-layout {
|
| 188 |
+
position: fixed;
|
| 189 |
+
inset: 0;
|
| 190 |
+
top: var(--header-h);
|
| 191 |
+
display: grid;
|
| 192 |
+
grid-template-columns: 200px 1fr 480px;
|
| 193 |
+
overflow: hidden;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/* ===== Nav Panel ===== */
|
| 197 |
+
.nav-panel {
|
| 198 |
+
background: var(--bg-secondary);
|
| 199 |
+
border-right: 1px solid var(--border);
|
| 200 |
+
display: flex;
|
| 201 |
+
flex-direction: column;
|
| 202 |
+
overflow: hidden;
|
| 203 |
+
}
|
| 204 |
+
.nav-panel-header {
|
| 205 |
+
padding: 14px;
|
| 206 |
+
border-bottom: 1px solid var(--border);
|
| 207 |
+
flex-shrink: 0;
|
| 208 |
+
}
|
| 209 |
+
.nav-panel-header h2 {
|
| 210 |
+
font-size: 0.8rem;
|
| 211 |
+
font-weight: 600;
|
| 212 |
+
color: var(--text-muted);
|
| 213 |
+
text-transform: uppercase;
|
| 214 |
+
letter-spacing: 0.08em;
|
| 215 |
+
margin-bottom: 10px;
|
| 216 |
+
}
|
| 217 |
+
.search-box {
|
| 218 |
+
display: flex; align-items: center; gap: 6px;
|
| 219 |
+
background: var(--bg-card);
|
| 220 |
+
border: 1px solid var(--border);
|
| 221 |
+
border-radius: var(--radius-sm);
|
| 222 |
+
padding: 6px 10px;
|
| 223 |
+
}
|
| 224 |
+
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
|
| 225 |
+
.search-box input {
|
| 226 |
+
background: transparent; border: none; outline: none;
|
| 227 |
+
color: var(--text-primary); font-size: 0.8rem; width: 100%;
|
| 228 |
+
}
|
| 229 |
+
.search-box input::placeholder { color: var(--text-muted); }
|
| 230 |
+
|
| 231 |
+
.sample-list {
|
| 232 |
+
flex: 1;
|
| 233 |
+
overflow-y: auto;
|
| 234 |
+
padding: 6px;
|
| 235 |
+
display: flex;
|
| 236 |
+
flex-direction: column;
|
| 237 |
+
gap: 2px;
|
| 238 |
+
}
|
| 239 |
+
.sample-item {
|
| 240 |
+
padding: 8px 10px;
|
| 241 |
+
border-radius: var(--radius-sm);
|
| 242 |
+
cursor: pointer;
|
| 243 |
+
transition: background 0.15s;
|
| 244 |
+
border: 1px solid transparent;
|
| 245 |
+
}
|
| 246 |
+
.sample-item:hover { background: var(--bg-hover); }
|
| 247 |
+
.sample-item.active {
|
| 248 |
+
background: rgba(88,166,255,0.1);
|
| 249 |
+
border-color: rgba(88,166,255,0.3);
|
| 250 |
+
}
|
| 251 |
+
.sample-item-idx {
|
| 252 |
+
font-size: 0.65rem;
|
| 253 |
+
color: var(--text-muted);
|
| 254 |
+
font-variant-numeric: tabular-nums;
|
| 255 |
+
margin-bottom: 2px;
|
| 256 |
+
}
|
| 257 |
+
.sample-item-emotion {
|
| 258 |
+
font-size: 0.75rem;
|
| 259 |
+
font-weight: 600;
|
| 260 |
+
margin-bottom: 2px;
|
| 261 |
+
}
|
| 262 |
+
.sample-item-problem {
|
| 263 |
+
font-size: 0.7rem;
|
| 264 |
+
color: var(--text-muted);
|
| 265 |
+
white-space: nowrap;
|
| 266 |
+
overflow: hidden;
|
| 267 |
+
text-overflow: ellipsis;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* ===== Conversation Panel ===== */
|
| 271 |
+
.conversation-panel {
|
| 272 |
+
display: flex;
|
| 273 |
+
flex-direction: column;
|
| 274 |
+
overflow: hidden;
|
| 275 |
+
background: var(--bg-primary);
|
| 276 |
+
}
|
| 277 |
+
.conv-header {
|
| 278 |
+
padding: 12px 20px;
|
| 279 |
+
border-bottom: 1px solid var(--border);
|
| 280 |
+
display: flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
justify-content: space-between;
|
| 283 |
+
flex-shrink: 0;
|
| 284 |
+
background: var(--bg-secondary);
|
| 285 |
+
}
|
| 286 |
+
.conv-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 287 |
+
.meta-badge {
|
| 288 |
+
padding: 3px 10px;
|
| 289 |
+
border-radius: 20px;
|
| 290 |
+
font-size: 0.72rem;
|
| 291 |
+
font-weight: 600;
|
| 292 |
+
letter-spacing: 0.03em;
|
| 293 |
+
}
|
| 294 |
+
.legend { display: flex; align-items: center; gap: 14px; }
|
| 295 |
+
.legend-item {
|
| 296 |
+
display: flex; align-items: center; gap: 5px;
|
| 297 |
+
font-size: 0.75rem; color: var(--text-muted);
|
| 298 |
+
}
|
| 299 |
+
.legend-dot {
|
| 300 |
+
width: 8px; height: 8px; border-radius: 2px;
|
| 301 |
+
}
|
| 302 |
+
.legend-dot.speaker { background: var(--speaker-border); }
|
| 303 |
+
.legend-dot.listener { background: var(--listener-border); }
|
| 304 |
+
|
| 305 |
+
.conversation-view {
|
| 306 |
+
flex: 1;
|
| 307 |
+
overflow-y: auto;
|
| 308 |
+
padding: 20px;
|
| 309 |
+
display: flex;
|
| 310 |
+
flex-direction: column;
|
| 311 |
+
gap: 8px;
|
| 312 |
+
}
|
| 313 |
+
.message-bubble {
|
| 314 |
+
max-width: 85%;
|
| 315 |
+
padding: 10px 14px;
|
| 316 |
+
border-radius: var(--radius);
|
| 317 |
+
border-left: 3px solid transparent;
|
| 318 |
+
font-size: 0.875rem;
|
| 319 |
+
line-height: 1.6;
|
| 320 |
+
position: relative;
|
| 321 |
+
transition: all 0.2s;
|
| 322 |
+
}
|
| 323 |
+
.message-bubble.speaker {
|
| 324 |
+
background: var(--speaker-bg);
|
| 325 |
+
border-color: var(--speaker-border);
|
| 326 |
+
align-self: flex-end;
|
| 327 |
+
border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
|
| 328 |
+
}
|
| 329 |
+
.message-bubble.listener {
|
| 330 |
+
background: var(--listener-bg);
|
| 331 |
+
border-color: var(--listener-border);
|
| 332 |
+
align-self: flex-start;
|
| 333 |
+
border-radius: var(--radius) var(--radius) var(--radius) var(--radius-sm);
|
| 334 |
+
}
|
| 335 |
+
.message-label {
|
| 336 |
+
font-size: 0.65rem;
|
| 337 |
+
font-weight: 700;
|
| 338 |
+
text-transform: uppercase;
|
| 339 |
+
letter-spacing: 0.08em;
|
| 340 |
+
margin-bottom: 4px;
|
| 341 |
+
opacity: 0.7;
|
| 342 |
+
}
|
| 343 |
+
.message-bubble.speaker .message-label { color: #60a5fa; }
|
| 344 |
+
.message-bubble.listener .message-label { color: #4ade80; }
|
| 345 |
+
|
| 346 |
+
/* Highlighted evidence text */
|
| 347 |
+
mark.evidence-hl {
|
| 348 |
+
background: transparent;
|
| 349 |
+
border-radius: 3px;
|
| 350 |
+
padding: 1px 0;
|
| 351 |
+
cursor: pointer;
|
| 352 |
+
transition: all 0.15s;
|
| 353 |
+
border-bottom: 2px solid transparent;
|
| 354 |
+
position: relative;
|
| 355 |
+
}
|
| 356 |
+
mark.evidence-hl:hover { filter: brightness(1.3); }
|
| 357 |
+
mark.evidence-pulse {
|
| 358 |
+
animation: evidence-glow 0.6s ease-in-out 3;
|
| 359 |
+
border-radius: 4px;
|
| 360 |
+
}
|
| 361 |
+
@keyframes evidence-glow {
|
| 362 |
+
0%, 100% { box-shadow: none; filter: brightness(1); }
|
| 363 |
+
50% { box-shadow: 0 0 12px 4px rgba(251, 191, 36, 0.5); filter: brightness(1.5); }
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* Distortion highlights */
|
| 367 |
+
mark.dist-hl-0 { background: var(--hl-distortion-1); border-bottom-color: #fb923c; color: inherit; }
|
| 368 |
+
mark.dist-hl-1 { background: var(--hl-distortion-2); border-bottom-color: #f87171; color: inherit; }
|
| 369 |
+
mark.dist-hl-2 { background: var(--hl-distortion-3); border-bottom-color: #fbbf24; color: inherit; }
|
| 370 |
+
|
| 371 |
+
/* Empathy dimension highlights */
|
| 372 |
+
mark.emp-hl-0 { background: var(--hl-empathy-concern); border-bottom-color: #60a5fa; color: inherit; }
|
| 373 |
+
mark.emp-hl-1 { background: var(--hl-empathy-expressiveness); border-bottom-color: #a78bfa; color: inherit; }
|
| 374 |
+
mark.emp-hl-2 { background: var(--hl-empathy-resonate); border-bottom-color: #34d399; color: inherit; }
|
| 375 |
+
mark.emp-hl-3 { background: var(--hl-empathy-warmth); border-bottom-color: #fbbf24; color: inherit; }
|
| 376 |
+
mark.emp-hl-4 { background: var(--hl-empathy-attuned); border-bottom-color: #fb923c; color: inherit; }
|
| 377 |
+
mark.emp-hl-5 { background: var(--hl-empathy-cognitive); border-bottom-color: #f87171; color: inherit; }
|
| 378 |
+
mark.emp-hl-6 { background: var(--hl-empathy-feelings); border-bottom-color: #818cf8; color: inherit; }
|
| 379 |
+
mark.emp-hl-7 { background: var(--hl-empathy-acceptance); border-bottom-color: #2dd4bf; color: inherit; }
|
| 380 |
+
mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-color: #e879f9; color: inherit; }
|
| 381 |
+
|
| 382 |
+
/* ===== Annotation Panel ===== */
|
| 383 |
+
.annotation-panel {
|
| 384 |
+
background: var(--bg-secondary);
|
| 385 |
+
border-left: 1px solid var(--border);
|
| 386 |
+
overflow-y: auto;
|
| 387 |
+
min-height: 0;
|
| 388 |
+
padding: 16px;
|
| 389 |
+
display: flex;
|
| 390 |
+
flex-direction: column;
|
| 391 |
+
gap: 14px;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.anno-card {
|
| 395 |
+
background: var(--bg-card);
|
| 396 |
+
border: 1px solid var(--border);
|
| 397 |
+
border-radius: var(--radius);
|
| 398 |
+
overflow: visible;
|
| 399 |
+
flex-shrink: 0;
|
| 400 |
+
transition: border-color 0.2s;
|
| 401 |
+
}
|
| 402 |
+
.anno-card:hover { border-color: var(--border-light); }
|
| 403 |
+
.anno-card-header {
|
| 404 |
+
padding: 13px 16px;
|
| 405 |
+
display: flex;
|
| 406 |
+
align-items: center;
|
| 407 |
+
gap: 10px;
|
| 408 |
+
border-bottom: 1px solid var(--border);
|
| 409 |
+
background: rgba(255,255,255,0.02);
|
| 410 |
+
}
|
| 411 |
+
.anno-card-header h3 {
|
| 412 |
+
font-size: 0.92rem;
|
| 413 |
+
font-weight: 600;
|
| 414 |
+
flex: 1;
|
| 415 |
+
}
|
| 416 |
+
.anno-card-icon {
|
| 417 |
+
width: 30px; height: 30px;
|
| 418 |
+
border-radius: var(--radius-sm);
|
| 419 |
+
display: flex; align-items: center; justify-content: center;
|
| 420 |
+
flex-shrink: 0;
|
| 421 |
+
}
|
| 422 |
+
.emotion-icon { background: var(--emotion-bg); color: var(--emotion-color); }
|
| 423 |
+
.distortion-icon { background: var(--distortion-bg); color: var(--distortion-color); }
|
| 424 |
+
.empathy-icon { background: var(--empathy-bg); color: var(--empathy-color); }
|
| 425 |
+
|
| 426 |
+
.anno-content { padding: 14px 16px; }
|
| 427 |
+
|
| 428 |
+
/* Emotion */
|
| 429 |
+
.emotion-primary {
|
| 430 |
+
display: flex; align-items: center; gap: 10px;
|
| 431 |
+
margin-bottom: 10px;
|
| 432 |
+
}
|
| 433 |
+
.emotion-badge {
|
| 434 |
+
padding: 6px 14px;
|
| 435 |
+
border-radius: 20px;
|
| 436 |
+
font-size: 0.85rem;
|
| 437 |
+
font-weight: 700;
|
| 438 |
+
background: var(--emotion-bg);
|
| 439 |
+
color: var(--emotion-color);
|
| 440 |
+
border: 1px solid rgba(167,139,250,0.3);
|
| 441 |
+
}
|
| 442 |
+
.intensity-badge {
|
| 443 |
+
padding: 4px 10px;
|
| 444 |
+
border-radius: var(--radius-sm);
|
| 445 |
+
font-size: 0.75rem;
|
| 446 |
+
font-weight: 600;
|
| 447 |
+
border: 1px solid;
|
| 448 |
+
}
|
| 449 |
+
.intensity-high { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
|
| 450 |
+
.intensity-medium { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
|
| 451 |
+
.intensity-low { background: rgba(52,211,153,0.15); color: #34d399; border-color: rgba(52,211,153,0.3); }
|
| 452 |
+
.emotion-reasoning {
|
| 453 |
+
font-size: 0.83rem;
|
| 454 |
+
color: var(--text-secondary);
|
| 455 |
+
line-height: 1.7;
|
| 456 |
+
padding: 10px 12px;
|
| 457 |
+
background: rgba(255,255,255,0.03);
|
| 458 |
+
border-radius: var(--radius-sm);
|
| 459 |
+
border-left: 3px solid var(--emotion-color);
|
| 460 |
+
}
|
| 461 |
+
.specific-term {
|
| 462 |
+
font-size: 0.78rem;
|
| 463 |
+
color: var(--text-muted);
|
| 464 |
+
margin-bottom: 8px;
|
| 465 |
+
}
|
| 466 |
+
.specific-term span { color: var(--emotion-color); font-weight: 600; }
|
| 467 |
+
|
| 468 |
+
/* Distortions */
|
| 469 |
+
.distortion-item {
|
| 470 |
+
padding: 12px 14px;
|
| 471 |
+
border-radius: var(--radius-sm);
|
| 472 |
+
border: 1px solid var(--border);
|
| 473 |
+
background: rgba(255,255,255,0.02);
|
| 474 |
+
margin-bottom: 10px;
|
| 475 |
+
cursor: pointer;
|
| 476 |
+
transition: all 0.15s;
|
| 477 |
+
position: relative;
|
| 478 |
+
}
|
| 479 |
+
.distortion-item:last-child { margin-bottom: 0; }
|
| 480 |
+
.distortion-item:hover { border-color: var(--distortion-color); background: var(--distortion-bg); }
|
| 481 |
+
.distortion-item.active { border-color: var(--distortion-color); background: var(--distortion-bg); }
|
| 482 |
+
.distortion-name {
|
| 483 |
+
font-size: 0.85rem;
|
| 484 |
+
font-weight: 700;
|
| 485 |
+
color: var(--distortion-color);
|
| 486 |
+
margin-bottom: 7px;
|
| 487 |
+
display: flex; align-items: center; gap: 8px;
|
| 488 |
+
}
|
| 489 |
+
.dist-color-dot {
|
| 490 |
+
width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0;
|
| 491 |
+
}
|
| 492 |
+
.distortion-quote {
|
| 493 |
+
font-size: 0.78rem;
|
| 494 |
+
color: var(--text-secondary);
|
| 495 |
+
font-style: italic;
|
| 496 |
+
margin-bottom: 7px;
|
| 497 |
+
padding: 6px 10px;
|
| 498 |
+
background: rgba(255,255,255,0.04);
|
| 499 |
+
border-radius: 4px;
|
| 500 |
+
border-left: 3px solid;
|
| 501 |
+
font-family: 'JetBrains Mono', monospace;
|
| 502 |
+
line-height: 1.5;
|
| 503 |
+
}
|
| 504 |
+
.distortion-reasoning {
|
| 505 |
+
font-size: 0.78rem;
|
| 506 |
+
color: var(--text-muted);
|
| 507 |
+
line-height: 1.6;
|
| 508 |
+
}
|
| 509 |
+
.no-distortions {
|
| 510 |
+
text-align: center;
|
| 511 |
+
padding: 20px;
|
| 512 |
+
color: var(--text-muted);
|
| 513 |
+
font-size: 0.85rem;
|
| 514 |
+
}
|
| 515 |
+
.no-distortions svg { margin: 0 auto 6px; display: block; opacity: 0.4; }
|
| 516 |
+
|
| 517 |
+
/* Empathy */
|
| 518 |
+
.overall-score {
|
| 519 |
+
margin-left: auto;
|
| 520 |
+
padding: 3px 10px;
|
| 521 |
+
background: var(--empathy-bg);
|
| 522 |
+
color: var(--empathy-color);
|
| 523 |
+
border-radius: 20px;
|
| 524 |
+
font-size: 0.75rem;
|
| 525 |
+
font-weight: 700;
|
| 526 |
+
border: 1px solid rgba(52,211,153,0.3);
|
| 527 |
+
}
|
| 528 |
+
.empathy-item {
|
| 529 |
+
padding: 12px 14px;
|
| 530 |
+
border-radius: var(--radius-sm);
|
| 531 |
+
border: 1px solid var(--border);
|
| 532 |
+
background: rgba(255,255,255,0.02);
|
| 533 |
+
margin-bottom: 8px;
|
| 534 |
+
cursor: pointer;
|
| 535 |
+
transition: all 0.15s;
|
| 536 |
+
}
|
| 537 |
+
.empathy-item:last-child { margin-bottom: 0; }
|
| 538 |
+
.empathy-item:hover { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.06); }
|
| 539 |
+
.empathy-item.active { border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
|
| 540 |
+
.empathy-item-header {
|
| 541 |
+
display: flex; align-items: center; gap: 10px;
|
| 542 |
+
margin-bottom: 8px;
|
| 543 |
+
}
|
| 544 |
+
.emp-color-dot {
|
| 545 |
+
width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0;
|
| 546 |
+
}
|
| 547 |
+
.empathy-name {
|
| 548 |
+
font-size: 0.82rem;
|
| 549 |
+
font-weight: 600;
|
| 550 |
+
flex: 1;
|
| 551 |
+
color: var(--text-primary);
|
| 552 |
+
}
|
| 553 |
+
.score-bar-container {
|
| 554 |
+
display: flex; align-items: center; gap: 10px;
|
| 555 |
+
flex-shrink: 0;
|
| 556 |
+
}
|
| 557 |
+
.score-bar {
|
| 558 |
+
width: 80px; height: 6px;
|
| 559 |
+
background: var(--border);
|
| 560 |
+
border-radius: 3px;
|
| 561 |
+
overflow: hidden;
|
| 562 |
+
}
|
| 563 |
+
.score-bar-fill {
|
| 564 |
+
height: 100%;
|
| 565 |
+
border-radius: 3px;
|
| 566 |
+
transition: width 0.4s ease;
|
| 567 |
+
}
|
| 568 |
+
.score-value {
|
| 569 |
+
font-size: 0.78rem;
|
| 570 |
+
font-weight: 700;
|
| 571 |
+
width: 28px;
|
| 572 |
+
text-align: right;
|
| 573 |
+
font-variant-numeric: tabular-nums;
|
| 574 |
+
}
|
| 575 |
+
.empathy-quote {
|
| 576 |
+
font-size: 0.76rem;
|
| 577 |
+
color: var(--text-secondary);
|
| 578 |
+
font-style: italic;
|
| 579 |
+
padding: 5px 10px;
|
| 580 |
+
background: rgba(255,255,255,0.03);
|
| 581 |
+
border-radius: 4px;
|
| 582 |
+
border-left: 3px solid;
|
| 583 |
+
font-family: 'JetBrains Mono', monospace;
|
| 584 |
+
margin-bottom: 6px;
|
| 585 |
+
line-height: 1.5;
|
| 586 |
+
white-space: normal;
|
| 587 |
+
word-break: break-word;
|
| 588 |
+
}
|
| 589 |
+
.empathy-reasoning {
|
| 590 |
+
font-size: 0.78rem;
|
| 591 |
+
color: var(--text-muted);
|
| 592 |
+
line-height: 1.6;
|
| 593 |
+
display: none;
|
| 594 |
+
}
|
| 595 |
+
.empathy-item.active .empathy-reasoning { display: block; }
|
| 596 |
+
|
| 597 |
+
/* ===== Tooltip ===== */
|
| 598 |
+
.tooltip {
|
| 599 |
+
position: fixed;
|
| 600 |
+
z-index: 999;
|
| 601 |
+
pointer-events: none;
|
| 602 |
+
opacity: 0;
|
| 603 |
+
transition: opacity 0.15s;
|
| 604 |
+
max-width: 320px;
|
| 605 |
+
}
|
| 606 |
+
.tooltip.visible { opacity: 1; pointer-events: auto; }
|
| 607 |
+
.tooltip-content {
|
| 608 |
+
background: #1c2128;
|
| 609 |
+
border: 1px solid var(--border);
|
| 610 |
+
border-radius: var(--radius);
|
| 611 |
+
padding: 12px 14px;
|
| 612 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 613 |
+
font-size: 0.78rem;
|
| 614 |
+
line-height: 1.6;
|
| 615 |
+
}
|
| 616 |
+
.tooltip-title {
|
| 617 |
+
font-weight: 700;
|
| 618 |
+
margin-bottom: 6px;
|
| 619 |
+
font-size: 0.82rem;
|
| 620 |
+
}
|
| 621 |
+
.tooltip-label {
|
| 622 |
+
font-size: 0.65rem;
|
| 623 |
+
text-transform: uppercase;
|
| 624 |
+
letter-spacing: 0.08em;
|
| 625 |
+
color: var(--text-muted);
|
| 626 |
+
margin-bottom: 3px;
|
| 627 |
+
margin-top: 8px;
|
| 628 |
+
}
|
| 629 |
+
.tooltip-text { color: var(--text-secondary); }
|
| 630 |
+
.tooltip-score {
|
| 631 |
+
display: inline-flex; align-items: center; gap: 4px;
|
| 632 |
+
padding: 2px 8px;
|
| 633 |
+
border-radius: 10px;
|
| 634 |
+
font-weight: 700;
|
| 635 |
+
font-size: 0.75rem;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* ===== Nav Arrows ===== */
|
| 639 |
+
.nav-arrows {
|
| 640 |
+
position: fixed;
|
| 641 |
+
bottom: 24px;
|
| 642 |
+
right: 500px;
|
| 643 |
+
display: flex;
|
| 644 |
+
gap: 8px;
|
| 645 |
+
z-index: 200;
|
| 646 |
+
}
|
| 647 |
+
.nav-btn {
|
| 648 |
+
width: 40px; height: 40px;
|
| 649 |
+
background: var(--bg-card);
|
| 650 |
+
border: 1px solid var(--border);
|
| 651 |
+
border-radius: 50%;
|
| 652 |
+
display: flex; align-items: center; justify-content: center;
|
| 653 |
+
cursor: pointer;
|
| 654 |
+
color: var(--text-secondary);
|
| 655 |
+
transition: all 0.2s;
|
| 656 |
+
}
|
| 657 |
+
.nav-btn:hover { background: var(--bg-hover); border-color: var(--accent); color: var(--accent); }
|
| 658 |
+
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
| 659 |
+
|
| 660 |
+
/* ===== Emotion colors by type ===== */
|
| 661 |
+
.emotion-fear { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
|
| 662 |
+
.emotion-sadness { background: rgba(96,165,250,0.15); color: #60a5fa; border-color: rgba(96,165,250,0.3); }
|
| 663 |
+
.emotion-anger { background: rgba(251,146,60,0.15); color: #fb923c; border-color: rgba(251,146,60,0.3); }
|
| 664 |
+
.emotion-joy { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
|
| 665 |
+
.emotion-anxiety { background: rgba(167,139,250,0.15); color: #a78bfa; border-color: rgba(167,139,250,0.3); }
|
| 666 |
+
.emotion-shame { background: rgba(232,121,249,0.15); color: #e879f9; border-color: rgba(232,121,249,0.3); }
|
| 667 |
+
.emotion-depression { background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); }
|
| 668 |
+
.emotion-default { background: var(--emotion-bg); color: var(--emotion-color); border: 1px solid rgba(167,139,250,0.3); }
|
| 669 |
+
|
| 670 |
+
/* Problem type badge */
|
| 671 |
+
.problem-badge {
|
| 672 |
+
padding: 3px 8px;
|
| 673 |
+
background: rgba(255,255,255,0.05);
|
| 674 |
+
border: 1px solid var(--border);
|
| 675 |
+
border-radius: var(--radius-sm);
|
| 676 |
+
font-size: 0.7rem;
|
| 677 |
+
color: var(--text-muted);
|
| 678 |
+
}
|
| 679 |
+
.experience-badge {
|
| 680 |
+
padding: 3px 8px;
|
| 681 |
+
background: rgba(88,166,255,0.08);
|
| 682 |
+
border: 1px solid rgba(88,166,255,0.2);
|
| 683 |
+
border-radius: var(--radius-sm);
|
| 684 |
+
font-size: 0.7rem;
|
| 685 |
+
color: var(--accent);
|
| 686 |
+
}
|
style.css
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== CSS Variables ===== */
|
| 2 |
+
:root {
|
| 3 |
+
--bg-primary: #0d1117;
|
| 4 |
+
--bg-secondary: #161b22;
|
| 5 |
+
--bg-card: #1c2128;
|
| 6 |
+
--bg-hover: #21262d;
|
| 7 |
+
--border: #30363d;
|
| 8 |
+
--border-light: #21262d;
|
| 9 |
+
--text-primary: #e6edf3;
|
| 10 |
+
--text-secondary: #8b949e;
|
| 11 |
+
--text-muted: #6e7681;
|
| 12 |
+
--accent: #58a6ff;
|
| 13 |
+
--accent-hover: #79c0ff;
|
| 14 |
+
|
| 15 |
+
--emotion-color: #a78bfa;
|
| 16 |
+
--emotion-bg: rgba(167, 139, 250, 0.12);
|
| 17 |
+
--distortion-color: #fb923c;
|
| 18 |
+
--distortion-bg: rgba(251, 146, 60, 0.12);
|
| 19 |
+
--empathy-color: #34d399;
|
| 20 |
+
--empathy-bg: rgba(52, 211, 153, 0.12);
|
| 21 |
+
--highlight-color: #fbbf24;
|
| 22 |
+
|
| 23 |
+
--speaker-bg: #1a2332;
|
| 24 |
+
--speaker-border: #1d4ed8;
|
| 25 |
+
--listener-bg: #1a2b1f;
|
| 26 |
+
--listener-border: #16a34a;
|
| 27 |
+
|
| 28 |
+
/* Highlight colors for evidence quotes */
|
| 29 |
+
--hl-distortion-1: rgba(251, 146, 60, 0.25);
|
| 30 |
+
--hl-distortion-2: rgba(248, 113, 113, 0.25);
|
| 31 |
+
--hl-distortion-3: rgba(251, 191, 36, 0.25);
|
| 32 |
+
|
| 33 |
+
--hl-empathy-concern: rgba(96, 165, 250, 0.22);
|
| 34 |
+
--hl-empathy-expressiveness: rgba(167, 139, 250, 0.22);
|
| 35 |
+
--hl-empathy-resonate: rgba(52, 211, 153, 0.22);
|
| 36 |
+
--hl-empathy-warmth: rgba(251, 191, 36, 0.22);
|
| 37 |
+
--hl-empathy-attuned: rgba(251, 146, 60, 0.22);
|
| 38 |
+
--hl-empathy-cognitive: rgba(248, 113, 113, 0.22);
|
| 39 |
+
--hl-empathy-feelings: rgba(129, 140, 248, 0.22);
|
| 40 |
+
--hl-empathy-acceptance: rgba(45, 212, 191, 0.22);
|
| 41 |
+
--hl-empathy-responsiveness: rgba(232, 121, 249, 0.22);
|
| 42 |
+
|
| 43 |
+
--radius-sm: 6px;
|
| 44 |
+
--radius: 10px;
|
| 45 |
+
--radius-lg: 14px;
|
| 46 |
+
--shadow: 0 4px 16px rgba(0,0,0,0.4);
|
| 47 |
+
--shadow-card: 0 2px 8px rgba(0,0,0,0.3);
|
| 48 |
+
|
| 49 |
+
--header-h: 60px;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* ===== Reset & Base ===== */
|
| 53 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 54 |
+
html { font-size: 14px; scroll-behavior: smooth; }
|
| 55 |
+
body {
|
| 56 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 57 |
+
background: var(--bg-primary);
|
| 58 |
+
color: var(--text-primary);
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
overflow: hidden;
|
| 61 |
+
}
|
| 62 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 63 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 64 |
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 65 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
| 66 |
+
|
| 67 |
+
/* ===== Header ===== */
|
| 68 |
+
.header {
|
| 69 |
+
position: fixed;
|
| 70 |
+
top: 0; left: 0; right: 0;
|
| 71 |
+
height: var(--header-h);
|
| 72 |
+
background: rgba(13, 17, 23, 0.92);
|
| 73 |
+
border-bottom: 1px solid var(--border);
|
| 74 |
+
backdrop-filter: blur(12px);
|
| 75 |
+
z-index: 100;
|
| 76 |
+
}
|
| 77 |
+
.header-inner {
|
| 78 |
+
max-width: 100%;
|
| 79 |
+
height: 100%;
|
| 80 |
+
padding: 0 20px;
|
| 81 |
+
display: flex;
|
| 82 |
+
align-items: center;
|
| 83 |
+
justify-content: space-between;
|
| 84 |
+
gap: 16px;
|
| 85 |
+
}
|
| 86 |
+
.header-logo {
|
| 87 |
+
display: flex;
|
| 88 |
+
align-items: center;
|
| 89 |
+
gap: 10px;
|
| 90 |
+
}
|
| 91 |
+
.logo-icon {
|
| 92 |
+
width: 36px; height: 36px;
|
| 93 |
+
background: linear-gradient(135deg, var(--accent), var(--empathy-color));
|
| 94 |
+
border-radius: var(--radius-sm);
|
| 95 |
+
display: flex; align-items: center; justify-content: center;
|
| 96 |
+
color: #fff;
|
| 97 |
+
flex-shrink: 0;
|
| 98 |
+
}
|
| 99 |
+
.header-logo h1 { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
|
| 100 |
+
.header-logo p { font-size: 0.75rem; color: var(--text-muted); }
|
| 101 |
+
.header-controls { display: flex; align-items: center; gap: 12px; }
|
| 102 |
+
|
| 103 |
+
.file-upload-area {
|
| 104 |
+
display: flex; align-items: center; gap: 8px;
|
| 105 |
+
padding: 7px 14px;
|
| 106 |
+
background: var(--bg-card);
|
| 107 |
+
border: 1px solid var(--border);
|
| 108 |
+
border-radius: var(--radius-sm);
|
| 109 |
+
cursor: pointer;
|
| 110 |
+
font-size: 0.85rem;
|
| 111 |
+
color: var(--text-secondary);
|
| 112 |
+
transition: all 0.2s;
|
| 113 |
+
position: relative;
|
| 114 |
+
overflow: hidden;
|
| 115 |
+
}
|
| 116 |
+
.file-upload-area:hover { border-color: var(--accent); color: var(--accent); background: var(--bg-hover); }
|
| 117 |
+
.file-upload-area input[type="file"] {
|
| 118 |
+
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
|
| 119 |
+
}
|
| 120 |
+
.sample-counter {
|
| 121 |
+
padding: 6px 14px;
|
| 122 |
+
background: var(--bg-card);
|
| 123 |
+
border: 1px solid var(--border);
|
| 124 |
+
border-radius: var(--radius-sm);
|
| 125 |
+
font-size: 0.85rem;
|
| 126 |
+
color: var(--text-secondary);
|
| 127 |
+
font-variant-numeric: tabular-nums;
|
| 128 |
+
}
|
| 129 |
+
.sample-counter #currentIdx { color: var(--accent); font-weight: 600; }
|
| 130 |
+
|
| 131 |
+
/* ===== Welcome Screen ===== */
|
| 132 |
+
.welcome-screen {
|
| 133 |
+
position: fixed;
|
| 134 |
+
inset: 0;
|
| 135 |
+
display: flex;
|
| 136 |
+
align-items: center;
|
| 137 |
+
justify-content: center;
|
| 138 |
+
background: var(--bg-primary);
|
| 139 |
+
z-index: 50;
|
| 140 |
+
padding: 20px;
|
| 141 |
+
}
|
| 142 |
+
.welcome-card {
|
| 143 |
+
max-width: 480px;
|
| 144 |
+
width: 100%;
|
| 145 |
+
background: var(--bg-card);
|
| 146 |
+
border: 1px solid var(--border);
|
| 147 |
+
border-radius: var(--radius-lg);
|
| 148 |
+
padding: 40px;
|
| 149 |
+
text-align: center;
|
| 150 |
+
box-shadow: var(--shadow);
|
| 151 |
+
}
|
| 152 |
+
.welcome-icon {
|
| 153 |
+
width: 80px; height: 80px;
|
| 154 |
+
background: linear-gradient(135deg, rgba(88,166,255,0.15), rgba(52,211,153,0.15));
|
| 155 |
+
border: 1px solid var(--border);
|
| 156 |
+
border-radius: 20px;
|
| 157 |
+
display: flex; align-items: center; justify-content: center;
|
| 158 |
+
margin: 0 auto 24px;
|
| 159 |
+
color: var(--accent);
|
| 160 |
+
}
|
| 161 |
+
.welcome-card h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 10px; }
|
| 162 |
+
.welcome-card p { color: var(--text-secondary); line-height: 1.6; margin-bottom: 28px; }
|
| 163 |
+
.welcome-features {
|
| 164 |
+
display: flex; flex-direction: column; gap: 10px;
|
| 165 |
+
margin-bottom: 32px;
|
| 166 |
+
text-align: left;
|
| 167 |
+
}
|
| 168 |
+
.feature-item {
|
| 169 |
+
display: flex; align-items: center; gap: 10px;
|
| 170 |
+
font-size: 0.875rem; color: var(--text-secondary);
|
| 171 |
+
}
|
| 172 |
+
.feature-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
| 173 |
+
.welcome-upload-btn {
|
| 174 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 175 |
+
padding: 12px 28px;
|
| 176 |
+
background: linear-gradient(135deg, var(--accent), #388bfd);
|
| 177 |
+
color: #fff;
|
| 178 |
+
border-radius: var(--radius-sm);
|
| 179 |
+
font-weight: 600;
|
| 180 |
+
cursor: pointer;
|
| 181 |
+
transition: opacity 0.2s, transform 0.2s;
|
| 182 |
+
font-size: 0.9rem;
|
| 183 |
+
}
|
| 184 |
+
.welcome-upload-btn:hover { opacity: 0.9; transform: translateY(-1px); }
|
| 185 |
+
|
| 186 |
+
/* ===== Main Layout ===== */
|
| 187 |
+
.main-layout {
|
| 188 |
+
position: fixed;
|
| 189 |
+
inset: 0;
|
| 190 |
+
top: var(--header-h);
|
| 191 |
+
display: grid;
|
| 192 |
+
grid-template-columns: 220px 1fr 340px;
|
| 193 |
+
overflow: hidden;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/* ===== Nav Panel ===== */
|
| 197 |
+
.nav-panel {
|
| 198 |
+
background: var(--bg-secondary);
|
| 199 |
+
border-right: 1px solid var(--border);
|
| 200 |
+
display: flex;
|
| 201 |
+
flex-direction: column;
|
| 202 |
+
overflow: hidden;
|
| 203 |
+
}
|
| 204 |
+
.nav-panel-header {
|
| 205 |
+
padding: 14px;
|
| 206 |
+
border-bottom: 1px solid var(--border);
|
| 207 |
+
flex-shrink: 0;
|
| 208 |
+
}
|
| 209 |
+
.nav-panel-header h2 {
|
| 210 |
+
font-size: 0.8rem;
|
| 211 |
+
font-weight: 600;
|
| 212 |
+
color: var(--text-muted);
|
| 213 |
+
text-transform: uppercase;
|
| 214 |
+
letter-spacing: 0.08em;
|
| 215 |
+
margin-bottom: 10px;
|
| 216 |
+
}
|
| 217 |
+
.search-box {
|
| 218 |
+
display: flex; align-items: center; gap: 6px;
|
| 219 |
+
background: var(--bg-card);
|
| 220 |
+
border: 1px solid var(--border);
|
| 221 |
+
border-radius: var(--radius-sm);
|
| 222 |
+
padding: 6px 10px;
|
| 223 |
+
}
|
| 224 |
+
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
|
| 225 |
+
.search-box input {
|
| 226 |
+
background: transparent; border: none; outline: none;
|
| 227 |
+
color: var(--text-primary); font-size: 0.8rem; width: 100%;
|
| 228 |
+
}
|
| 229 |
+
.search-box input::placeholder { color: var(--text-muted); }
|
| 230 |
+
|
| 231 |
+
.sample-list {
|
| 232 |
+
flex: 1;
|
| 233 |
+
overflow-y: auto;
|
| 234 |
+
padding: 6px;
|
| 235 |
+
display: flex;
|
| 236 |
+
flex-direction: column;
|
| 237 |
+
gap: 2px;
|
| 238 |
+
}
|
| 239 |
+
.sample-item {
|
| 240 |
+
padding: 8px 10px;
|
| 241 |
+
border-radius: var(--radius-sm);
|
| 242 |
+
cursor: pointer;
|
| 243 |
+
transition: background 0.15s;
|
| 244 |
+
border: 1px solid transparent;
|
| 245 |
+
}
|
| 246 |
+
.sample-item:hover { background: var(--bg-hover); }
|
| 247 |
+
.sample-item.active {
|
| 248 |
+
background: rgba(88,166,255,0.1);
|
| 249 |
+
border-color: rgba(88,166,255,0.3);
|
| 250 |
+
}
|
| 251 |
+
.sample-item-idx {
|
| 252 |
+
font-size: 0.65rem;
|
| 253 |
+
color: var(--text-muted);
|
| 254 |
+
font-variant-numeric: tabular-nums;
|
| 255 |
+
margin-bottom: 2px;
|
| 256 |
+
}
|
| 257 |
+
.sample-item-emotion {
|
| 258 |
+
font-size: 0.75rem;
|
| 259 |
+
font-weight: 600;
|
| 260 |
+
margin-bottom: 2px;
|
| 261 |
+
}
|
| 262 |
+
.sample-item-problem {
|
| 263 |
+
font-size: 0.7rem;
|
| 264 |
+
color: var(--text-muted);
|
| 265 |
+
white-space: nowrap;
|
| 266 |
+
overflow: hidden;
|
| 267 |
+
text-overflow: ellipsis;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* ===== Conversation Panel ===== */
|
| 271 |
+
.conversation-panel {
|
| 272 |
+
display: flex;
|
| 273 |
+
flex-direction: column;
|
| 274 |
+
overflow: hidden;
|
| 275 |
+
background: var(--bg-primary);
|
| 276 |
+
}
|
| 277 |
+
.conv-header {
|
| 278 |
+
padding: 12px 20px;
|
| 279 |
+
border-bottom: 1px solid var(--border);
|
| 280 |
+
display: flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
justify-content: space-between;
|
| 283 |
+
flex-shrink: 0;
|
| 284 |
+
background: var(--bg-secondary);
|
| 285 |
+
}
|
| 286 |
+
.conv-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 287 |
+
.meta-badge {
|
| 288 |
+
padding: 3px 10px;
|
| 289 |
+
border-radius: 20px;
|
| 290 |
+
font-size: 0.72rem;
|
| 291 |
+
font-weight: 600;
|
| 292 |
+
letter-spacing: 0.03em;
|
| 293 |
+
}
|
| 294 |
+
.legend { display: flex; align-items: center; gap: 14px; }
|
| 295 |
+
.legend-item {
|
| 296 |
+
display: flex; align-items: center; gap: 5px;
|
| 297 |
+
font-size: 0.75rem; color: var(--text-muted);
|
| 298 |
+
}
|
| 299 |
+
.legend-dot {
|
| 300 |
+
width: 8px; height: 8px; border-radius: 2px;
|
| 301 |
+
}
|
| 302 |
+
.legend-dot.speaker { background: var(--speaker-border); }
|
| 303 |
+
.legend-dot.listener { background: var(--listener-border); }
|
| 304 |
+
|
| 305 |
+
.conversation-view {
|
| 306 |
+
flex: 1;
|
| 307 |
+
overflow-y: auto;
|
| 308 |
+
padding: 20px;
|
| 309 |
+
display: flex;
|
| 310 |
+
flex-direction: column;
|
| 311 |
+
gap: 8px;
|
| 312 |
+
}
|
| 313 |
+
.message-bubble {
|
| 314 |
+
max-width: 85%;
|
| 315 |
+
padding: 10px 14px;
|
| 316 |
+
border-radius: var(--radius);
|
| 317 |
+
border-left: 3px solid transparent;
|
| 318 |
+
font-size: 0.875rem;
|
| 319 |
+
line-height: 1.6;
|
| 320 |
+
position: relative;
|
| 321 |
+
transition: all 0.2s;
|
| 322 |
+
}
|
| 323 |
+
.message-bubble.speaker {
|
| 324 |
+
background: var(--speaker-bg);
|
| 325 |
+
border-color: var(--speaker-border);
|
| 326 |
+
align-self: flex-end;
|
| 327 |
+
border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
|
| 328 |
+
}
|
| 329 |
+
.message-bubble.listener {
|
| 330 |
+
background: var(--listener-bg);
|
| 331 |
+
border-color: var(--listener-border);
|
| 332 |
+
align-self: flex-start;
|
| 333 |
+
border-radius: var(--radius) var(--radius) var(--radius) var(--radius-sm);
|
| 334 |
+
}
|
| 335 |
+
.message-label {
|
| 336 |
+
font-size: 0.65rem;
|
| 337 |
+
font-weight: 700;
|
| 338 |
+
text-transform: uppercase;
|
| 339 |
+
letter-spacing: 0.08em;
|
| 340 |
+
margin-bottom: 4px;
|
| 341 |
+
opacity: 0.7;
|
| 342 |
+
}
|
| 343 |
+
.message-bubble.speaker .message-label { color: #60a5fa; }
|
| 344 |
+
.message-bubble.listener .message-label { color: #4ade80; }
|
| 345 |
+
|
| 346 |
+
/* Highlighted evidence text */
|
| 347 |
+
mark.evidence-hl {
|
| 348 |
+
background: transparent;
|
| 349 |
+
border-radius: 3px;
|
| 350 |
+
padding: 1px 0;
|
| 351 |
+
cursor: pointer;
|
| 352 |
+
transition: all 0.15s;
|
| 353 |
+
border-bottom: 2px solid transparent;
|
| 354 |
+
position: relative;
|
| 355 |
+
}
|
| 356 |
+
mark.evidence-hl:hover { filter: brightness(1.3); }
|
| 357 |
+
mark.evidence-pulse {
|
| 358 |
+
animation: evidence-glow 0.6s ease-in-out 3;
|
| 359 |
+
border-radius: 4px;
|
| 360 |
+
}
|
| 361 |
+
@keyframes evidence-glow {
|
| 362 |
+
0%, 100% { box-shadow: none; filter: brightness(1); }
|
| 363 |
+
50% { box-shadow: 0 0 12px 4px rgba(251, 191, 36, 0.5); filter: brightness(1.5); }
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* Distortion highlights */
|
| 367 |
+
mark.dist-hl-0 { background: var(--hl-distortion-1); border-bottom-color: #fb923c; color: inherit; }
|
| 368 |
+
mark.dist-hl-1 { background: var(--hl-distortion-2); border-bottom-color: #f87171; color: inherit; }
|
| 369 |
+
mark.dist-hl-2 { background: var(--hl-distortion-3); border-bottom-color: #fbbf24; color: inherit; }
|
| 370 |
+
|
| 371 |
+
/* Empathy dimension highlights */
|
| 372 |
+
mark.emp-hl-0 { background: var(--hl-empathy-concern); border-bottom-color: #60a5fa; color: inherit; }
|
| 373 |
+
mark.emp-hl-1 { background: var(--hl-empathy-expressiveness); border-bottom-color: #a78bfa; color: inherit; }
|
| 374 |
+
mark.emp-hl-2 { background: var(--hl-empathy-resonate); border-bottom-color: #34d399; color: inherit; }
|
| 375 |
+
mark.emp-hl-3 { background: var(--hl-empathy-warmth); border-bottom-color: #fbbf24; color: inherit; }
|
| 376 |
+
mark.emp-hl-4 { background: var(--hl-empathy-attuned); border-bottom-color: #fb923c; color: inherit; }
|
| 377 |
+
mark.emp-hl-5 { background: var(--hl-empathy-cognitive); border-bottom-color: #f87171; color: inherit; }
|
| 378 |
+
mark.emp-hl-6 { background: var(--hl-empathy-feelings); border-bottom-color: #818cf8; color: inherit; }
|
| 379 |
+
mark.emp-hl-7 { background: var(--hl-empathy-acceptance); border-bottom-color: #2dd4bf; color: inherit; }
|
| 380 |
+
mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-color: #e879f9; color: inherit; }
|
| 381 |
+
|
| 382 |
+
/* ===== Annotation Panel ===== */
|
| 383 |
+
.annotation-panel {
|
| 384 |
+
background: var(--bg-secondary);
|
| 385 |
+
border-left: 1px solid var(--border);
|
| 386 |
+
overflow-y: auto;
|
| 387 |
+
min-height: 0;
|
| 388 |
+
padding: 14px;
|
| 389 |
+
display: flex;
|
| 390 |
+
flex-direction: column;
|
| 391 |
+
gap: 12px;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.anno-card {
|
| 395 |
+
background: var(--bg-card);
|
| 396 |
+
border: 1px solid var(--border);
|
| 397 |
+
border-radius: var(--radius);
|
| 398 |
+
overflow: visible;
|
| 399 |
+
flex-shrink: 0;
|
| 400 |
+
transition: border-color 0.2s;
|
| 401 |
+
}
|
| 402 |
+
.anno-card:hover { border-color: var(--border-light); }
|
| 403 |
+
.anno-card-header {
|
| 404 |
+
padding: 12px 14px;
|
| 405 |
+
display: flex;
|
| 406 |
+
align-items: center;
|
| 407 |
+
gap: 8px;
|
| 408 |
+
border-bottom: 1px solid var(--border);
|
| 409 |
+
background: rgba(255,255,255,0.02);
|
| 410 |
+
}
|
| 411 |
+
.anno-card-header h3 {
|
| 412 |
+
font-size: 0.85rem;
|
| 413 |
+
font-weight: 600;
|
| 414 |
+
flex: 1;
|
| 415 |
+
}
|
| 416 |
+
.anno-card-icon {
|
| 417 |
+
width: 28px; height: 28px;
|
| 418 |
+
border-radius: var(--radius-sm);
|
| 419 |
+
display: flex; align-items: center; justify-content: center;
|
| 420 |
+
flex-shrink: 0;
|
| 421 |
+
}
|
| 422 |
+
.emotion-icon { background: var(--emotion-bg); color: var(--emotion-color); }
|
| 423 |
+
.distortion-icon { background: var(--distortion-bg); color: var(--distortion-color); }
|
| 424 |
+
.empathy-icon { background: var(--empathy-bg); color: var(--empathy-color); }
|
| 425 |
+
|
| 426 |
+
.anno-content { padding: 12px 14px; }
|
| 427 |
+
|
| 428 |
+
/* Emotion */
|
| 429 |
+
.emotion-primary {
|
| 430 |
+
display: flex; align-items: center; gap: 10px;
|
| 431 |
+
margin-bottom: 10px;
|
| 432 |
+
}
|
| 433 |
+
.emotion-badge {
|
| 434 |
+
padding: 5px 12px;
|
| 435 |
+
border-radius: 20px;
|
| 436 |
+
font-size: 0.8rem;
|
| 437 |
+
font-weight: 700;
|
| 438 |
+
background: var(--emotion-bg);
|
| 439 |
+
color: var(--emotion-color);
|
| 440 |
+
border: 1px solid rgba(167,139,250,0.3);
|
| 441 |
+
}
|
| 442 |
+
.intensity-badge {
|
| 443 |
+
padding: 3px 8px;
|
| 444 |
+
border-radius: var(--radius-sm);
|
| 445 |
+
font-size: 0.7rem;
|
| 446 |
+
font-weight: 600;
|
| 447 |
+
border: 1px solid;
|
| 448 |
+
}
|
| 449 |
+
.intensity-high { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
|
| 450 |
+
.intensity-medium { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
|
| 451 |
+
.intensity-low { background: rgba(52,211,153,0.15); color: #34d399; border-color: rgba(52,211,153,0.3); }
|
| 452 |
+
.emotion-reasoning {
|
| 453 |
+
font-size: 0.78rem;
|
| 454 |
+
color: var(--text-secondary);
|
| 455 |
+
line-height: 1.6;
|
| 456 |
+
padding: 8px 10px;
|
| 457 |
+
background: rgba(255,255,255,0.03);
|
| 458 |
+
border-radius: var(--radius-sm);
|
| 459 |
+
border-left: 2px solid var(--emotion-color);
|
| 460 |
+
}
|
| 461 |
+
.specific-term {
|
| 462 |
+
font-size: 0.72rem;
|
| 463 |
+
color: var(--text-muted);
|
| 464 |
+
margin-bottom: 6px;
|
| 465 |
+
}
|
| 466 |
+
.specific-term span { color: var(--emotion-color); font-weight: 600; }
|
| 467 |
+
|
| 468 |
+
/* Distortions */
|
| 469 |
+
.distortion-item {
|
| 470 |
+
padding: 10px;
|
| 471 |
+
border-radius: var(--radius-sm);
|
| 472 |
+
border: 1px solid var(--border);
|
| 473 |
+
background: rgba(255,255,255,0.02);
|
| 474 |
+
margin-bottom: 8px;
|
| 475 |
+
cursor: pointer;
|
| 476 |
+
transition: all 0.15s;
|
| 477 |
+
position: relative;
|
| 478 |
+
}
|
| 479 |
+
.distortion-item:last-child { margin-bottom: 0; }
|
| 480 |
+
.distortion-item:hover { border-color: var(--distortion-color); background: var(--distortion-bg); }
|
| 481 |
+
.distortion-item.active { border-color: var(--distortion-color); background: var(--distortion-bg); }
|
| 482 |
+
.distortion-name {
|
| 483 |
+
font-size: 0.78rem;
|
| 484 |
+
font-weight: 700;
|
| 485 |
+
color: var(--distortion-color);
|
| 486 |
+
margin-bottom: 5px;
|
| 487 |
+
display: flex; align-items: center; gap: 6px;
|
| 488 |
+
}
|
| 489 |
+
.dist-color-dot {
|
| 490 |
+
width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0;
|
| 491 |
+
}
|
| 492 |
+
.distortion-quote {
|
| 493 |
+
font-size: 0.72rem;
|
| 494 |
+
color: var(--text-secondary);
|
| 495 |
+
font-style: italic;
|
| 496 |
+
margin-bottom: 5px;
|
| 497 |
+
padding: 4px 8px;
|
| 498 |
+
background: rgba(255,255,255,0.04);
|
| 499 |
+
border-radius: 4px;
|
| 500 |
+
border-left: 2px solid;
|
| 501 |
+
font-family: 'JetBrains Mono', monospace;
|
| 502 |
+
}
|
| 503 |
+
.distortion-reasoning {
|
| 504 |
+
font-size: 0.72rem;
|
| 505 |
+
color: var(--text-muted);
|
| 506 |
+
line-height: 1.5;
|
| 507 |
+
}
|
| 508 |
+
.no-distortions {
|
| 509 |
+
text-align: center;
|
| 510 |
+
padding: 16px;
|
| 511 |
+
color: var(--text-muted);
|
| 512 |
+
font-size: 0.8rem;
|
| 513 |
+
}
|
| 514 |
+
.no-distortions svg { margin: 0 auto 6px; display: block; opacity: 0.4; }
|
| 515 |
+
|
| 516 |
+
/* Empathy */
|
| 517 |
+
.overall-score {
|
| 518 |
+
margin-left: auto;
|
| 519 |
+
padding: 3px 10px;
|
| 520 |
+
background: var(--empathy-bg);
|
| 521 |
+
color: var(--empathy-color);
|
| 522 |
+
border-radius: 20px;
|
| 523 |
+
font-size: 0.75rem;
|
| 524 |
+
font-weight: 700;
|
| 525 |
+
border: 1px solid rgba(52,211,153,0.3);
|
| 526 |
+
}
|
| 527 |
+
.empathy-item {
|
| 528 |
+
padding: 10px;
|
| 529 |
+
border-radius: var(--radius-sm);
|
| 530 |
+
border: 1px solid var(--border);
|
| 531 |
+
background: rgba(255,255,255,0.02);
|
| 532 |
+
margin-bottom: 6px;
|
| 533 |
+
cursor: pointer;
|
| 534 |
+
transition: all 0.15s;
|
| 535 |
+
}
|
| 536 |
+
.empathy-item:last-child { margin-bottom: 0; }
|
| 537 |
+
.empathy-item:hover { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.06); }
|
| 538 |
+
.empathy-item.active { border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
|
| 539 |
+
.empathy-item-header {
|
| 540 |
+
display: flex; align-items: center; gap: 8px;
|
| 541 |
+
margin-bottom: 6px;
|
| 542 |
+
}
|
| 543 |
+
.emp-color-dot {
|
| 544 |
+
width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0;
|
| 545 |
+
}
|
| 546 |
+
.empathy-name {
|
| 547 |
+
font-size: 0.75rem;
|
| 548 |
+
font-weight: 600;
|
| 549 |
+
flex: 1;
|
| 550 |
+
color: var(--text-primary);
|
| 551 |
+
}
|
| 552 |
+
.score-bar-container {
|
| 553 |
+
display: flex; align-items: center; gap: 8px;
|
| 554 |
+
flex-shrink: 0;
|
| 555 |
+
}
|
| 556 |
+
.score-bar {
|
| 557 |
+
width: 60px; height: 5px;
|
| 558 |
+
background: var(--border);
|
| 559 |
+
border-radius: 3px;
|
| 560 |
+
overflow: hidden;
|
| 561 |
+
}
|
| 562 |
+
.score-bar-fill {
|
| 563 |
+
height: 100%;
|
| 564 |
+
border-radius: 3px;
|
| 565 |
+
transition: width 0.4s ease;
|
| 566 |
+
}
|
| 567 |
+
.score-value {
|
| 568 |
+
font-size: 0.72rem;
|
| 569 |
+
font-weight: 700;
|
| 570 |
+
width: 24px;
|
| 571 |
+
text-align: right;
|
| 572 |
+
font-variant-numeric: tabular-nums;
|
| 573 |
+
}
|
| 574 |
+
.empathy-quote {
|
| 575 |
+
font-size: 0.7rem;
|
| 576 |
+
color: var(--text-muted);
|
| 577 |
+
font-style: italic;
|
| 578 |
+
padding: 3px 7px;
|
| 579 |
+
background: rgba(255,255,255,0.03);
|
| 580 |
+
border-radius: 3px;
|
| 581 |
+
border-left: 2px solid;
|
| 582 |
+
font-family: 'JetBrains Mono', monospace;
|
| 583 |
+
margin-bottom: 4px;
|
| 584 |
+
overflow: hidden;
|
| 585 |
+
text-overflow: ellipsis;
|
| 586 |
+
white-space: nowrap;
|
| 587 |
+
}
|
| 588 |
+
.empathy-reasoning {
|
| 589 |
+
font-size: 0.7rem;
|
| 590 |
+
color: var(--text-muted);
|
| 591 |
+
line-height: 1.5;
|
| 592 |
+
display: none;
|
| 593 |
+
}
|
| 594 |
+
.empathy-item.active .empathy-reasoning { display: block; }
|
| 595 |
+
|
| 596 |
+
/* ===== Tooltip ===== */
|
| 597 |
+
.tooltip {
|
| 598 |
+
position: fixed;
|
| 599 |
+
z-index: 999;
|
| 600 |
+
pointer-events: none;
|
| 601 |
+
opacity: 0;
|
| 602 |
+
transition: opacity 0.15s;
|
| 603 |
+
max-width: 320px;
|
| 604 |
+
}
|
| 605 |
+
.tooltip.visible { opacity: 1; pointer-events: auto; }
|
| 606 |
+
.tooltip-content {
|
| 607 |
+
background: #1c2128;
|
| 608 |
+
border: 1px solid var(--border);
|
| 609 |
+
border-radius: var(--radius);
|
| 610 |
+
padding: 12px 14px;
|
| 611 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 612 |
+
font-size: 0.78rem;
|
| 613 |
+
line-height: 1.6;
|
| 614 |
+
}
|
| 615 |
+
.tooltip-title {
|
| 616 |
+
font-weight: 700;
|
| 617 |
+
margin-bottom: 6px;
|
| 618 |
+
font-size: 0.82rem;
|
| 619 |
+
}
|
| 620 |
+
.tooltip-label {
|
| 621 |
+
font-size: 0.65rem;
|
| 622 |
+
text-transform: uppercase;
|
| 623 |
+
letter-spacing: 0.08em;
|
| 624 |
+
color: var(--text-muted);
|
| 625 |
+
margin-bottom: 3px;
|
| 626 |
+
margin-top: 8px;
|
| 627 |
+
}
|
| 628 |
+
.tooltip-text { color: var(--text-secondary); }
|
| 629 |
+
.tooltip-score {
|
| 630 |
+
display: inline-flex; align-items: center; gap: 4px;
|
| 631 |
+
padding: 2px 8px;
|
| 632 |
+
border-radius: 10px;
|
| 633 |
+
font-weight: 700;
|
| 634 |
+
font-size: 0.75rem;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
/* ===== Nav Arrows ===== */
|
| 638 |
+
.nav-arrows {
|
| 639 |
+
position: fixed;
|
| 640 |
+
bottom: 24px;
|
| 641 |
+
right: 360px;
|
| 642 |
+
display: flex;
|
| 643 |
+
gap: 8px;
|
| 644 |
+
z-index: 200;
|
| 645 |
+
}
|
| 646 |
+
.nav-btn {
|
| 647 |
+
width: 40px; height: 40px;
|
| 648 |
+
background: var(--bg-card);
|
| 649 |
+
border: 1px solid var(--border);
|
| 650 |
+
border-radius: 50%;
|
| 651 |
+
display: flex; align-items: center; justify-content: center;
|
| 652 |
+
cursor: pointer;
|
| 653 |
+
color: var(--text-secondary);
|
| 654 |
+
transition: all 0.2s;
|
| 655 |
+
}
|
| 656 |
+
.nav-btn:hover { background: var(--bg-hover); border-color: var(--accent); color: var(--accent); }
|
| 657 |
+
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
| 658 |
+
|
| 659 |
+
/* ===== Emotion colors by type ===== */
|
| 660 |
+
.emotion-fear { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
|
| 661 |
+
.emotion-sadness { background: rgba(96,165,250,0.15); color: #60a5fa; border-color: rgba(96,165,250,0.3); }
|
| 662 |
+
.emotion-anger { background: rgba(251,146,60,0.15); color: #fb923c; border-color: rgba(251,146,60,0.3); }
|
| 663 |
+
.emotion-joy { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
|
| 664 |
+
.emotion-anxiety { background: rgba(167,139,250,0.15); color: #a78bfa; border-color: rgba(167,139,250,0.3); }
|
| 665 |
+
.emotion-shame { background: rgba(232,121,249,0.15); color: #e879f9; border-color: rgba(232,121,249,0.3); }
|
| 666 |
+
.emotion-depression { background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); }
|
| 667 |
+
.emotion-default { background: var(--emotion-bg); color: var(--emotion-color); border: 1px solid rgba(167,139,250,0.3); }
|
| 668 |
+
|
| 669 |
+
/* Problem type badge */
|
| 670 |
+
.problem-badge {
|
| 671 |
+
padding: 3px 8px;
|
| 672 |
+
background: rgba(255,255,255,0.05);
|
| 673 |
+
border: 1px solid var(--border);
|
| 674 |
+
border-radius: var(--radius-sm);
|
| 675 |
+
font-size: 0.7rem;
|
| 676 |
+
color: var(--text-muted);
|
| 677 |
+
}
|
| 678 |
+
.experience-badge {
|
| 679 |
+
padding: 3px 8px;
|
| 680 |
+
background: rgba(88,166,255,0.08);
|
| 681 |
+
border: 1px solid rgba(88,166,255,0.2);
|
| 682 |
+
border-radius: var(--radius-sm);
|
| 683 |
+
font-size: 0.7rem;
|
| 684 |
+
color: var(--accent);
|
| 685 |
+
}
|
templates/admin.html
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>Admin — ESConv Annotation</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css"/>
|
| 11 |
+
<link rel="stylesheet" href="/static/admin.css"/>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<!-- Header -->
|
| 15 |
+
<header class="header">
|
| 16 |
+
<div class="header-inner">
|
| 17 |
+
<div class="header-logo">
|
| 18 |
+
<div class="logo-icon">
|
| 19 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 20 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 21 |
+
</svg>
|
| 22 |
+
</div>
|
| 23 |
+
<div>
|
| 24 |
+
<h1>Admin Dashboard</h1>
|
| 25 |
+
<p>Manage files, users & assignments</p>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="header-controls">
|
| 29 |
+
<span class="user-badge" id="userBadge">admin</span>
|
| 30 |
+
<button class="logout-btn" id="logoutBtn">
|
| 31 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 32 |
+
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
|
| 33 |
+
</svg>
|
| 34 |
+
Logout
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</header>
|
| 39 |
+
|
| 40 |
+
<main class="admin-main">
|
| 41 |
+
<!-- Tab Navigation -->
|
| 42 |
+
<div class="tab-nav">
|
| 43 |
+
<button class="tab-btn active" data-tab="files">📁 CSV Files</button>
|
| 44 |
+
<button class="tab-btn" data-tab="users">👥 Users</button>
|
| 45 |
+
<button class="tab-btn" data-tab="assignments">📋 Assignments</button>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<!-- Files Tab -->
|
| 49 |
+
<div class="tab-content active" id="tab-files">
|
| 50 |
+
<div class="section-header">
|
| 51 |
+
<h2>CSV Files</h2>
|
| 52 |
+
<p>Files in <code id="csvFolderPath"></code></p>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="card-grid" id="filesList"></div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<!-- Users Tab -->
|
| 58 |
+
<div class="tab-content" id="tab-users">
|
| 59 |
+
<div class="section-header">
|
| 60 |
+
<h2>Annotator Accounts</h2>
|
| 61 |
+
<button class="primary-btn" id="addUserBtn">
|
| 62 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 63 |
+
<path d="M12 5v14M5 12h14"/>
|
| 64 |
+
</svg>
|
| 65 |
+
Add User
|
| 66 |
+
</button>
|
| 67 |
+
</div>
|
| 68 |
+
<!-- Create User Form (hidden) -->
|
| 69 |
+
<div class="create-form" id="createUserForm" style="display:none">
|
| 70 |
+
<div class="form-row">
|
| 71 |
+
<input type="text" id="newUsername" placeholder="Username"/>
|
| 72 |
+
<input type="password" id="newPassword" placeholder="Password"/>
|
| 73 |
+
<select id="newRole"><option value="annotator">Annotator</option><option value="admin">Admin</option></select>
|
| 74 |
+
<button class="primary-btn" id="submitUserBtn">Create</button>
|
| 75 |
+
<button class="ghost-btn" id="cancelUserBtn">Cancel</button>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="form-error" id="userFormError"></div>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="table-container">
|
| 80 |
+
<table class="data-table" id="usersTable">
|
| 81 |
+
<thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
|
| 82 |
+
<tbody id="usersBody"></tbody>
|
| 83 |
+
</table>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<!-- Assignments Tab -->
|
| 88 |
+
<div class="tab-content" id="tab-assignments">
|
| 89 |
+
<div class="section-header">
|
| 90 |
+
<h2>File Assignments</h2>
|
| 91 |
+
<button class="primary-btn" id="addAssignBtn">
|
| 92 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 93 |
+
<path d="M12 5v14M5 12h14"/>
|
| 94 |
+
</svg>
|
| 95 |
+
Assign File
|
| 96 |
+
</button>
|
| 97 |
+
</div>
|
| 98 |
+
<!-- Assign Form (hidden) -->
|
| 99 |
+
<div class="create-form" id="createAssignForm" style="display:none">
|
| 100 |
+
<div class="form-row">
|
| 101 |
+
<select id="assignUser"><option value="">Select User...</option></select>
|
| 102 |
+
<select id="assignFile"><option value="">Select File...</option></select>
|
| 103 |
+
<button class="primary-btn" id="submitAssignBtn">Assign</button>
|
| 104 |
+
<button class="ghost-btn" id="cancelAssignBtn">Cancel</button>
|
| 105 |
+
</div>
|
| 106 |
+
<div class="form-error" id="assignFormError"></div>
|
| 107 |
+
</div>
|
| 108 |
+
<div class="table-container">
|
| 109 |
+
<table class="data-table" id="assignTable">
|
| 110 |
+
<thead><tr><th>Annotator</th><th>File</th><th>Progress</th><th>Assigned</th><th>Actions</th></tr></thead>
|
| 111 |
+
<tbody id="assignBody"></tbody>
|
| 112 |
+
</table>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</main>
|
| 116 |
+
<script src="/static/admin.js"></script>
|
| 117 |
+
</body>
|
| 118 |
+
</html>
|
templates/annotator.html
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>Annotator — ESConv Annotation</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css"/>
|
| 11 |
+
<link rel="stylesheet" href="/static/admin.css"/>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<header class="header">
|
| 15 |
+
<div class="header-inner">
|
| 16 |
+
<div class="header-logo">
|
| 17 |
+
<div class="logo-icon">
|
| 18 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 19 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 20 |
+
</svg>
|
| 21 |
+
</div>
|
| 22 |
+
<div>
|
| 23 |
+
<h1>My Assignments</h1>
|
| 24 |
+
<p>Review LLM annotations</p>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="header-controls">
|
| 28 |
+
<span class="user-badge" id="userBadge">annotator</span>
|
| 29 |
+
<button class="logout-btn" id="logoutBtn">
|
| 30 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 31 |
+
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
|
| 32 |
+
</svg>
|
| 33 |
+
Logout
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</header>
|
| 38 |
+
|
| 39 |
+
<main class="admin-main">
|
| 40 |
+
<div class="section-header">
|
| 41 |
+
<h2>Assigned Files</h2>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="card-grid" id="assignmentCards"></div>
|
| 44 |
+
<div class="empty-state" id="emptyState" style="display:none">
|
| 45 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.3;margin-bottom:12px">
|
| 46 |
+
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
| 47 |
+
</svg>
|
| 48 |
+
<p>No files assigned to you yet.</p>
|
| 49 |
+
<p style="font-size:.8rem;color:var(--text-muted)">Contact admin to get files assigned.</p>
|
| 50 |
+
</div>
|
| 51 |
+
</main>
|
| 52 |
+
|
| 53 |
+
<script>
|
| 54 |
+
// Fetch user info
|
| 55 |
+
fetch('/api/me').then(r=>r.json()).then(u=>{
|
| 56 |
+
document.getElementById('userBadge').textContent = u.username;
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
// Logout
|
| 60 |
+
document.getElementById('logoutBtn').addEventListener('click', async()=>{
|
| 61 |
+
await fetch('/api/logout',{method:'POST'});
|
| 62 |
+
window.location.href = '/login';
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// Load assignments
|
| 66 |
+
async function loadAssignments() {
|
| 67 |
+
const res = await fetch('/api/my-assignments');
|
| 68 |
+
const data = await res.json();
|
| 69 |
+
const grid = document.getElementById('assignmentCards');
|
| 70 |
+
const empty = document.getElementById('emptyState');
|
| 71 |
+
|
| 72 |
+
if (data.length === 0) { empty.style.display='block'; grid.innerHTML=''; return; }
|
| 73 |
+
empty.style.display='none';
|
| 74 |
+
|
| 75 |
+
grid.innerHTML = data.map(a => {
|
| 76 |
+
const pct = a.progress || 0;
|
| 77 |
+
const barColor = pct >= 100 ? '#34d399' : pct > 50 ? '#fbbf24' : '#60a5fa';
|
| 78 |
+
return `
|
| 79 |
+
<div class="file-card" onclick="window.location.href='/review/${a.id}'">
|
| 80 |
+
<div class="file-card-header">
|
| 81 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)">
|
| 82 |
+
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
| 83 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 84 |
+
</svg>
|
| 85 |
+
<span class="file-card-name">${a.filename}</span>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="file-card-stats">
|
| 88 |
+
<span>${a.reviewed_count} / ${a.total_samples} reviewed</span>
|
| 89 |
+
<span style="color:${barColor};font-weight:700">${pct}%</span>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="progress-bar">
|
| 92 |
+
<div class="progress-fill" style="width:${pct}%;background:${barColor}"></div>
|
| 93 |
+
</div>
|
| 94 |
+
${pct >= 100 ? '<div class="completed-badge">✓ Completed</div>' : '<div class="continue-badge">Continue Review →</div>'}
|
| 95 |
+
</div>`;
|
| 96 |
+
}).join('');
|
| 97 |
+
}
|
| 98 |
+
loadAssignments();
|
| 99 |
+
</script>
|
| 100 |
+
</body>
|
| 101 |
+
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>Login — ESConv Annotation</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css"/>
|
| 11 |
+
<style>
|
| 12 |
+
body { display:flex; align-items:center; justify-content:center; min-height:100vh; overflow:auto; }
|
| 13 |
+
.login-card {
|
| 14 |
+
max-width:400px; width:100%;
|
| 15 |
+
background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius-lg);
|
| 16 |
+
padding:40px; box-shadow:var(--shadow);
|
| 17 |
+
}
|
| 18 |
+
.login-card h1 { text-align:center; font-size:1.5rem; margin-bottom:6px; }
|
| 19 |
+
.login-card p { text-align:center; color:var(--text-muted); font-size:.85rem; margin-bottom:28px; }
|
| 20 |
+
.login-icon {
|
| 21 |
+
width:64px; height:64px; margin:0 auto 20px;
|
| 22 |
+
background:linear-gradient(135deg,rgba(88,166,255,.15),rgba(52,211,153,.15));
|
| 23 |
+
border:1px solid var(--border); border-radius:16px;
|
| 24 |
+
display:flex; align-items:center; justify-content:center; color:var(--accent);
|
| 25 |
+
}
|
| 26 |
+
.form-group { margin-bottom:16px; }
|
| 27 |
+
.form-group label { display:block; font-size:.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:6px; }
|
| 28 |
+
.form-group input {
|
| 29 |
+
width:100%; padding:10px 14px;
|
| 30 |
+
background:var(--bg-primary); border:1px solid var(--border); border-radius:var(--radius-sm);
|
| 31 |
+
color:var(--text-primary); font-size:.9rem; outline:none; transition:border-color .2s;
|
| 32 |
+
}
|
| 33 |
+
.form-group input:focus { border-color:var(--accent); }
|
| 34 |
+
.login-btn {
|
| 35 |
+
width:100%; padding:12px;
|
| 36 |
+
background:linear-gradient(135deg,var(--accent),#388bfd); color:#fff;
|
| 37 |
+
border:none; border-radius:var(--radius-sm); font-weight:600; font-size:.9rem;
|
| 38 |
+
cursor:pointer; transition:opacity .2s;
|
| 39 |
+
}
|
| 40 |
+
.login-btn:hover { opacity:.9; }
|
| 41 |
+
.login-error {
|
| 42 |
+
padding:8px 12px; background:rgba(248,113,113,.1); border:1px solid rgba(248,113,113,.3);
|
| 43 |
+
border-radius:var(--radius-sm); color:#f87171; font-size:.8rem; margin-bottom:14px;
|
| 44 |
+
display:none; text-align:center;
|
| 45 |
+
}
|
| 46 |
+
.login-error.show { display:block; }
|
| 47 |
+
</style>
|
| 48 |
+
</head>
|
| 49 |
+
<body>
|
| 50 |
+
<div class="login-card">
|
| 51 |
+
<div class="login-icon">
|
| 52 |
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 53 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 54 |
+
</svg>
|
| 55 |
+
</div>
|
| 56 |
+
<h1>ESConv Annotation</h1>
|
| 57 |
+
<p>Sign in to review annotations</p>
|
| 58 |
+
<div class="login-error" id="loginError"></div>
|
| 59 |
+
<form id="loginForm">
|
| 60 |
+
<div class="form-group">
|
| 61 |
+
<label for="username">Username</label>
|
| 62 |
+
<input type="text" id="username" name="username" autocomplete="username" autofocus required/>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="form-group">
|
| 65 |
+
<label for="password">Password</label>
|
| 66 |
+
<input type="password" id="password" name="password" autocomplete="current-password" required/>
|
| 67 |
+
</div>
|
| 68 |
+
<button type="submit" class="login-btn">Sign In</button>
|
| 69 |
+
</form>
|
| 70 |
+
</div>
|
| 71 |
+
<script>
|
| 72 |
+
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
| 73 |
+
e.preventDefault();
|
| 74 |
+
const errEl = document.getElementById('loginError');
|
| 75 |
+
errEl.classList.remove('show');
|
| 76 |
+
const username = document.getElementById('username').value.trim();
|
| 77 |
+
const password = document.getElementById('password').value;
|
| 78 |
+
try {
|
| 79 |
+
const res = await fetch('/api/login', {
|
| 80 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 81 |
+
body: JSON.stringify({ username, password })
|
| 82 |
+
});
|
| 83 |
+
const data = await res.json();
|
| 84 |
+
if (res.ok) {
|
| 85 |
+
window.location.href = '/';
|
| 86 |
+
} else {
|
| 87 |
+
errEl.textContent = data.error || 'Login failed';
|
| 88 |
+
errEl.classList.add('show');
|
| 89 |
+
}
|
| 90 |
+
} catch(err) {
|
| 91 |
+
errEl.textContent = 'Network error';
|
| 92 |
+
errEl.classList.add('show');
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
</script>
|
| 96 |
+
</body>
|
| 97 |
+
</html>
|
templates/review.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>Review — ESConv Annotation</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css"/>
|
| 11 |
+
<link rel="stylesheet" href="/static/admin.css"/>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<!-- Header -->
|
| 15 |
+
<header class="header">
|
| 16 |
+
<div class="header-inner">
|
| 17 |
+
<div class="header-logo">
|
| 18 |
+
<a href="/" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit">
|
| 19 |
+
<div class="logo-icon">
|
| 20 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 21 |
+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 22 |
+
</svg>
|
| 23 |
+
</div>
|
| 24 |
+
<div>
|
| 25 |
+
<h1 id="reviewTitle">ESConv Review</h1>
|
| 26 |
+
<p id="reviewSubtitle">Loading...</p>
|
| 27 |
+
</div>
|
| 28 |
+
</a>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="header-controls">
|
| 31 |
+
<div class="sample-counter" id="sampleCounter">
|
| 32 |
+
<span id="currentIdx">0</span> / <span id="totalSamples">0</span>
|
| 33 |
+
</div>
|
| 34 |
+
<span class="user-badge" id="userBadge"></span>
|
| 35 |
+
<div class="export-dropdown" style="position:relative">
|
| 36 |
+
<button class="logout-btn" id="exportBtn" style="padding:6px 12px;font-size:.8rem;border-color:var(--empathy-color);color:var(--empathy-color)">
|
| 37 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 38 |
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
| 39 |
+
</svg>
|
| 40 |
+
Export
|
| 41 |
+
</button>
|
| 42 |
+
<div class="export-menu" id="exportMenu" style="display:none;position:absolute;top:100%;right:0;margin-top:6px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow);z-index:200;min-width:160px;overflow:hidden">
|
| 43 |
+
<button class="export-option" id="exportJSON" style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;background:none;border:none;color:var(--text-primary);font-size:.82rem;cursor:pointer;font-family:inherit;text-align:left;transition:background .15s" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">📄 Export JSON</button>
|
| 44 |
+
<button class="export-option" id="exportCSV" style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;background:none;border:none;color:var(--text-primary);font-size:.82rem;cursor:pointer;font-family:inherit;text-align:left;border-top:1px solid var(--border);transition:background .15s" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">📊 Export CSV</button>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
<button class="logout-btn" id="logoutBtn" style="padding:6px 12px;font-size:.8rem">
|
| 48 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 49 |
+
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
|
| 50 |
+
</svg>
|
| 51 |
+
Logout
|
| 52 |
+
</button>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</header>
|
| 56 |
+
|
| 57 |
+
<!-- Main Layout -->
|
| 58 |
+
<main class="main-layout" id="mainLayout">
|
| 59 |
+
<!-- Left: Navigation Panel -->
|
| 60 |
+
<aside class="nav-panel">
|
| 61 |
+
<div class="nav-panel-header">
|
| 62 |
+
<h2>Samples</h2>
|
| 63 |
+
<div class="search-box">
|
| 64 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 65 |
+
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
| 66 |
+
</svg>
|
| 67 |
+
<input type="text" id="searchInput" placeholder="Filter..."/>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="sample-list" id="sampleList"></div>
|
| 71 |
+
</aside>
|
| 72 |
+
|
| 73 |
+
<!-- Center: Conversation View -->
|
| 74 |
+
<section class="conversation-panel">
|
| 75 |
+
<div class="conv-header">
|
| 76 |
+
<div class="conv-meta" id="convMeta"></div>
|
| 77 |
+
<div class="legend">
|
| 78 |
+
<span class="legend-item"><span class="legend-dot speaker"></span>Speaker</span>
|
| 79 |
+
<span class="legend-item"><span class="legend-dot listener"></span>Listener</span>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="selection-hint" id="selectionHint">✏️ Bôi đen đoạn text trong hội thoại để chọn evidence — nhấn <strong>Esc</strong> để huỷ</div>
|
| 83 |
+
<div class="conversation-view" id="conversationView"></div>
|
| 84 |
+
</section>
|
| 85 |
+
|
| 86 |
+
<!-- Right: Annotation + Review Panel -->
|
| 87 |
+
<aside class="annotation-panel">
|
| 88 |
+
<!-- Emotion Card -->
|
| 89 |
+
<div class="anno-card">
|
| 90 |
+
<div class="anno-card-header">
|
| 91 |
+
<div class="anno-card-icon emotion-icon">
|
| 92 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 93 |
+
<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>
|
| 94 |
+
</svg>
|
| 95 |
+
</div>
|
| 96 |
+
<h3>Emotion</h3>
|
| 97 |
+
<button class="edit-toggle-btn" id="editEmotionBtn" data-card="emotion">✏️ Edit</button>
|
| 98 |
+
</div>
|
| 99 |
+
<div id="emotionContent" class="anno-content"></div>
|
| 100 |
+
</div>
|
| 101 |
+
<!-- Distortions Card -->
|
| 102 |
+
<div class="anno-card">
|
| 103 |
+
<div class="anno-card-header">
|
| 104 |
+
<div class="anno-card-icon distortion-icon">
|
| 105 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 106 |
+
<path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
| 107 |
+
</svg>
|
| 108 |
+
</div>
|
| 109 |
+
<h3>Distortions</h3>
|
| 110 |
+
<button class="edit-toggle-btn" id="editDistBtn" data-card="distortions">✏️ Edit</button>
|
| 111 |
+
</div>
|
| 112 |
+
<div id="distortionsContent" class="anno-content"></div>
|
| 113 |
+
</div>
|
| 114 |
+
<!-- Empathy Card -->
|
| 115 |
+
<div class="anno-card">
|
| 116 |
+
<div class="anno-card-header">
|
| 117 |
+
<div class="anno-card-icon empathy-icon">
|
| 118 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 119 |
+
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
|
| 120 |
+
</svg>
|
| 121 |
+
</div>
|
| 122 |
+
<h3>Empathy</h3>
|
| 123 |
+
<div class="overall-score" id="overallScore"></div>
|
| 124 |
+
<button class="edit-toggle-btn" id="editEmpBtn" data-card="empathy">✏️ Edit</button>
|
| 125 |
+
</div>
|
| 126 |
+
<div id="empathyContent" class="anno-content"></div>
|
| 127 |
+
</div>
|
| 128 |
+
<!-- Review Card -->
|
| 129 |
+
<div class="anno-card review-card">
|
| 130 |
+
<div class="anno-card-header">
|
| 131 |
+
<div class="anno-card-icon" style="background:rgba(88,166,255,.12);color:var(--accent)">
|
| 132 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 133 |
+
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
| 134 |
+
</svg>
|
| 135 |
+
</div>
|
| 136 |
+
<h3>Your Review</h3>
|
| 137 |
+
<div class="review-status-badge" id="reviewStatusBadge" style="display:none"></div>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="anno-content">
|
| 140 |
+
<div class="review-buttons">
|
| 141 |
+
<button class="review-btn accept-btn" id="acceptBtn">✓ Accept</button>
|
| 142 |
+
<button class="review-btn reject-btn" id="rejectBtn">✗ Reject</button>
|
| 143 |
+
</div>
|
| 144 |
+
<textarea class="review-comment" id="reviewComment" placeholder="Add a comment (optional)..." rows="3"></textarea>
|
| 145 |
+
<button class="submit-review-btn" id="submitReviewBtn">Submit Review</button>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</aside>
|
| 149 |
+
</main>
|
| 150 |
+
|
| 151 |
+
<!-- Tooltip -->
|
| 152 |
+
<div class="tooltip" id="tooltip">
|
| 153 |
+
<div class="tooltip-content" id="tooltipContent"></div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<!-- Nav Arrows -->
|
| 157 |
+
<div class="nav-arrows" id="navArrows">
|
| 158 |
+
<button class="nav-btn" id="prevBtn" title="Previous">
|
| 159 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
| 160 |
+
</button>
|
| 161 |
+
<button class="nav-btn" id="nextBtn" title="Next">
|
| 162 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<script src="/static/app.js"></script>
|
| 167 |
+
</body>
|
| 168 |
+
</html>
|