feat: komplette Codebase vom Board ins Repo gespiegelt

app/ = /opt/callrec (Node-Backend, Vite-Frontend, Python-Inferenz,
Audio-Skripte, rkllm_server.cpp), system/ = systemd-Units,
WirePlumber/PipeWire-Drop-ins, sudoers. Modelle, Binaries, Aufnahmen
und node_modules bleiben draussen (.gitignore).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-16 11:18:29 +02:00
parent 560622e125
commit f9e1eb82c4
61 changed files with 5546 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aime</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+928
View File
@@ -0,0 +1,928 @@
{
"name": "callrec-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "callrec-web",
"devDependencies": {
"vite": "^5.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
"integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
"integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
"integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
"integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
"integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
"integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
"integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
"integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
"integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
"integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
"integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
"integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
"integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
"integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
"integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
"integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
"integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
"integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
"integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
"integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
"integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
"integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
"integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
"integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.62.2",
"@rollup/rollup-android-arm64": "4.62.2",
"@rollup/rollup-darwin-arm64": "4.62.2",
"@rollup/rollup-darwin-x64": "4.62.2",
"@rollup/rollup-freebsd-arm64": "4.62.2",
"@rollup/rollup-freebsd-x64": "4.62.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
"@rollup/rollup-linux-arm-musleabihf": "4.62.2",
"@rollup/rollup-linux-arm64-gnu": "4.62.2",
"@rollup/rollup-linux-arm64-musl": "4.62.2",
"@rollup/rollup-linux-loong64-gnu": "4.62.2",
"@rollup/rollup-linux-loong64-musl": "4.62.2",
"@rollup/rollup-linux-ppc64-gnu": "4.62.2",
"@rollup/rollup-linux-ppc64-musl": "4.62.2",
"@rollup/rollup-linux-riscv64-gnu": "4.62.2",
"@rollup/rollup-linux-riscv64-musl": "4.62.2",
"@rollup/rollup-linux-s390x-gnu": "4.62.2",
"@rollup/rollup-linux-x64-gnu": "4.62.2",
"@rollup/rollup-linux-x64-musl": "4.62.2",
"@rollup/rollup-openbsd-x64": "4.62.2",
"@rollup/rollup-openharmony-arm64": "4.62.2",
"@rollup/rollup-win32-arm64-msvc": "4.62.2",
"@rollup/rollup-win32-ia32-msvc": "4.62.2",
"@rollup/rollup-win32-x64-gnu": "4.62.2",
"@rollup/rollup-win32-x64-msvc": "4.62.2",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "callrec-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"vite": "^5.4.0"
}
}
+504
View File
@@ -0,0 +1,504 @@
import './style.css';
const api = {
async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); }
};
const app = document.querySelector('#app');
const state = { tab: 'record', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null };
function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }
function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); }
function render() {
app.classList.toggle('wide', state.tab === 'recordings');
app.innerHTML = `
<header>
<h1>📞 Aime <span class="sub">Anruf-Assistent · RK3588S</span></h1>
<nav>
${['record', 'bluetooth', 'recordings', 'voices', 'glossary'].map(t =>
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ record: 'Aufnahme', bluetooth: 'Bluetooth', recordings: 'Aufnahmen', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
</nav>
</header>
<main id="view"></main>`;
app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); });
({ record: viewRecord, bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab]();
}
// ---------- Bluetooth ----------
async function viewBluetooth() {
const v = document.querySelector('#view');
v.innerHTML = `
<div class="card">
<div class="row">
<button id="scan" class="btn primary">🔍 Geräte suchen (10s)</button>
<button id="refresh" class="btn">Aktualisieren</button>
<span id="btmsg" class="muted"></span>
</div>
<div id="btlist" class="list">Lade…</div>
</div>`;
const listEl = v.querySelector('#btlist');
const msg = v.querySelector('#btmsg');
async function load(devices) {
const d = devices || await api.get('/api/bt/devices');
if (!d.length) { listEl.innerHTML = '<div class="muted">Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).</div>'; return; }
listEl.innerHTML = d.map(dev => `
<div class="item">
<div class="grow">
<b>${dev.name || '(unbenannt)'}</b> ${dev.audio ? '🎧' : ''}
<div class="mono muted">${dev.mac}</div>
</div>
<span class="badge ${dev.connected ? 'ok' : dev.paired ? 'warn' : ''}">${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'}</span>
${dev.connected
? `<button class="btn" data-act="disconnect" data-mac="${dev.mac}">Trennen</button>`
: dev.paired
? `<button class="btn primary" data-act="connect" data-mac="${dev.mac}">Verbinden</button>`
: `<button class="btn primary" data-act="pair" data-mac="${dev.mac}">Pairen</button>`}
${dev.audio ? `<button class="btn" data-act="repair" data-mac="${dev.mac}" title="Bond löschen und komplett neu koppeln — wenn Verbinden/Pairen scheitert (Headset in Pairing-Modus)">Neu verbinden</button>` : ''}
</div>`).join('');
listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b));
}
async function act(a, mac, btn) {
btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…';
try {
const r = await api.post('/api/bt/' + a, { mac });
if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok);
else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`);
if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {});
}
catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); }
await load();
}
v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; };
v.querySelector('#refresh').onclick = () => load();
load().catch(e => listEl.innerHTML = '<div class="err">' + e.message + '</div>');
}
// ---------- Record ----------
async function viewRecord() {
const v = document.querySelector('#view');
v.innerHTML = `
<div class="card">
<label class="lbl">Audioquelle (Mikrofon)</label>
<div class="row">
<select id="src" class="grow"></select>
<button id="reloadsrc" class="btn">↻</button>
</div>
<label class="lbl">HFP-Codec (nur Bluetooth) — A/B-Test</label>
<select id="codec" class="grow">
<option value="auto" selected>Auto (mSBC bevorzugt)</option>
<option value="msbc">mSBC — 16 kHz Breitband</option>
<option value="cvsd">CVSD — 8 kHz Schmalband</option>
</select>
<label class="lbl">Sprecheranzahl (optional)</label>
<input id="nspk" class="input" type="number" min="1" max="8" placeholder="leer = automatisch erkennen"/>
<div class="hint">Wenn du die Anzahl der Sprecher kennst, hier eintragen — verbessert die Trennung. Sonst leer lassen (Auto). Titel, Zusammenfassung, ToDos & Sprecher-Erkennung laufen automatisch per KI.</div>
<div class="rec-panel">
<div id="timer" class="timer">00:00</div>
<button id="recbtn" class="btn big ${state.recording ? 'danger' : 'primary'}">${state.recording ? '■ Stop & Transkribieren' : '● Aufnahme starten'}</button>
</div>
<div id="recmsg" class="muted"></div>
</div>`;
const sel = v.querySelector('#src');
async function loadSrc() {
try {
const s = await api.get('/api/audio/sources');
sel.innerHTML = '<option value="">(Standardquelle)</option>' + s.map(x => `<option value="${x.name}" ${x.isBluetooth ? 'selected' : ''}>${x.isBluetooth ? '🎧 ' : '🎙️ '}${x.label || x.name}</option>`).join('');
} catch { sel.innerHTML = '<option value="">(keine gefunden)</option>'; }
}
await loadSrc();
v.querySelector('#reloadsrc').onclick = loadSrc;
const timerEl = v.querySelector('#timer');
if (state.recording) startTimerUI(timerEl);
v.querySelector('#recbtn').onclick = async (e) => {
if (!state.recording) {
try {
await api.post('/api/record/start', { source: sel.value, codec: v.querySelector('#codec').value, numSpeakers: +v.querySelector('#nspk').value || undefined });
state.recording = true; state.recStart = Date.now(); render();
} catch (err) { toast(err.message, true); }
} else {
e.target.disabled = true; e.target.textContent = 'Transkribiere… (kann dauern)';
clearInterval(state.timer);
try {
const r = await api.post('/api/record/stop', {});
state.recording = false;
toast('Aufnahme gespeichert – wird transkribiert…');
state.tab = 'recordings'; render(); openRecording(r.id);
} catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); }
}
};
}
function startTimerUI(el) { clearInterval(state.timer); state.timer = setInterval(() => { el.textContent = fmt(Date.now() - state.recStart); }, 500); }
// ---------- Voices (enrollment) ----------
async function viewVoices() {
const v = document.querySelector('#view');
v.innerHTML = `
<div class="card">
<h3>Stimme anlernen</h3>
<div class="hint">Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.</div>
<label class="lbl">Name</label>
<input id="vname" class="input" placeholder="z.B. Sandra" value="${escapeHtml(state.enrollName || '')}" ${state.enrolling ? 'disabled' : ''}/>
<div class="rec-panel">
<div id="vtimer" class="timer">00:00</div>
<button id="vbtn" class="btn big ${state.enrolling ? 'danger' : 'primary'}">${state.enrolling ? '■ Stop & Speichern' : '● Aufnahme starten'}</button>
</div>
<div id="vmsg" class="muted"></div>
</div>
<div class="card"><h3>Angelernte Stimmen</h3><div id="vplist" class="list">Lade…</div></div>`;
const listEl = v.querySelector('#vplist');
async function load() {
try {
const vps = await api.get('/api/voiceprints');
listEl.innerHTML = vps.length ? vps.map(p => `
<div class="item"><div class="grow"><b>👤 ${escapeHtml(p.name)}</b>
<div class="muted">${new Date(p.created_at).toLocaleString('de-DE')} · ${p.dim}-dim</div></div>
<button class="btn danger" data-del="${p.id}">🗑</button></div>`).join('')
: '<div class="muted">Noch keine Stimmen. Oben anlernen.</div>';
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/voiceprints/' + b.dataset.del); load(); });
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
}
await load();
const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer');
if (state.enrolling) startTimerUI(timerEl);
btn.onclick = async () => {
if (!state.enrolling) {
const name = v.querySelector('#vname').value.trim();
if (!name) { toast('Bitte Namen eingeben', true); return; }
state.enrollName = name;
try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); }
catch (e) { toast(e.message, true); }
} else {
btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer);
try {
const r = await api.post('/api/enroll/stop', { name: state.enrollName });
state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render();
} catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); }
}
};
}
// ---------- Glossary ----------
async function viewGlossary() {
const v = document.querySelector('#view');
v.innerHTML = `
<div class="card">
<h3>Begriff hinzufügen</h3>
<div class="hint">Eigennamen &amp; Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.</div>
<label class="lbl">Begriff (korrekte Schreibweise)</label>
<input id="gterm" class="input" placeholder="z.B. Aime"/>
<label class="lbl">Verhörer / Soundalikes (optional, Komma-getrennt)</label>
<input id="gsound" class="input" placeholder="z.B. Amy, Ami"/>
<button id="gadd" class="btn primary">Hinzufügen</button>
</div>
<div class="card"><h3>Glossar</h3><div id="glist" class="list">Lade…</div></div>`;
const listEl = v.querySelector('#glist');
async function load() {
try {
const gs = await api.get('/api/glossary');
listEl.innerHTML = gs.length ? gs.map(g => `
<div class="item"><div class="grow"><b>📖 ${escapeHtml(g.term)}</b>
${g.soundalikes ? `<div class="muted">↔ ${escapeHtml(g.soundalikes)}</div>` : ''}</div>
<button class="btn danger" data-del="${g.id}">🗑</button></div>`).join('')
: '<div class="muted">Noch keine Begriffe. Oben hinzufügen.</div>';
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); load(); });
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
}
await load();
v.querySelector('#gadd').onclick = async () => {
const term = v.querySelector('#gterm').value.trim();
if (!term) { toast('Bitte Begriff eingeben', true); return; }
const soundalikes = v.querySelector('#gsound').value.trim();
try {
await api.post('/api/glossary', { term, soundalikes });
toast(`„${term}" gespeichert ✓`);
v.querySelector('#gterm').value = ''; v.querySelector('#gsound').value = '';
load();
} catch (e) { toast(e.message, true); }
};
}
// ---------- Recordings ----------
// Analysis-duration counter. Shows the total wall time of a processing run
// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
// frozen to analysis_ms once done. Returns the inner <span> only; callers add the ⏱.
function anaTimeSpan(r) {
if (r.analysis_ms != null) return `<span class="ana-time">${fmt(r.analysis_ms)}</span>`;
if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing'))
return `<span class="ana-time live" data-ana-start="${r.analysis_started_at}">${fmt(Date.now() - r.analysis_started_at)}</span>`;
return '';
}
function ensureAnaTicker() {
if (state.anaTicker) return;
state.anaTicker = setInterval(() => {
document.querySelectorAll('.ana-time.live').forEach(el => {
const t0 = +el.dataset.anaStart;
if (t0) el.textContent = fmt(Date.now() - t0);
});
}, 1000);
}
// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the
// selected recording's detail (with its own audio player). Crucially, the
// while-analysing auto-refresh only rebuilds the LEFT list — it never touches
// the RIGHT detail pane, so you can keep listening to an old recording while a
// new one is being transcribed/analysed (the old bug: the whole view — detail +
// audio element — was re-rendered every 5s, stopping playback).
function viewRecordings() {
const v = document.querySelector('#view');
v.innerHTML = `
<div class="rec-layout">
<aside class="rec-list-pane"><div id="reclist" class="list">Lade…</div></aside>
<section class="rec-detail-pane"><div id="detail"><div class="detail-empty muted">← Aufnahme wählen</div></div></section>
</div>`;
refreshRecList();
if (state.selectedId != null) openRecording(state.selectedId);
}
// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh
// timer; safe to run while the right pane holds a playing recording.
async function refreshRecList() {
const listEl = document.querySelector('#reclist');
if (!listEl) return; // left the tab
let recs;
try { recs = await api.get('/api/recordings'); }
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
// Reschedule the poll ONLY while something is still processing.
const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing');
const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); };
try {
if (!recs.length) { listEl.innerHTML = '<div class="muted">Noch keine Aufnahmen.</div>'; state.lastListSig = 'empty'; scheduleNext(); return; }
// Signature of everything the list RENDERS except the live ⏱ seconds (those
// tick via ensureAnaTicker without a rebuild). If nothing meaningful changed,
// DON'T touch the DOM at all — no flicker, no scroll jump, selection kept.
// This is the real fix for "I get pulled away from my recording every 5s":
// the 5s poll now no-ops unless a status/title/todo actually changed.
const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}`).join('~');
if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; }
state.lastListSig = sig;
// Preserve the left pane's scroll position across the rebuild.
const scroller = listEl.closest('.rec-list-pane') || listEl;
const savedTop = scroller.scrollTop;
// Group by calendar day (recs come newest-first). Each day is a collapsible
// section; the day header shows the recording count.
const groups = [];
const byDay = {};
for (const r of recs) {
const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
if (!byDay[day]) { byDay[day] = []; groups.push(day); }
byDay[day].push(r);
}
listEl.innerHTML = groups.map(day => {
const collapsed = state.collapsedDates.has(day);
const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
<div class="item rec ${state.selectedId == r.id ? 'sel' : ''}" data-open="${r.id}">
<div class="grow">
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b> ${analysisBadge(r.analysis_status)}
<div class="muted">${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}</div>
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
</div>
<button class="btn danger icon" data-del="${r.id}" title="Löschen">🗑</button>
</div>`; }).join('');
return `
<div class="day-group ${collapsed ? 'collapsed' : ''}">
<div class="day-head" data-day="${escapeHtml(day)}"><span class="day-caret">▾</span> ${escapeHtml(day)} <span class="day-count">${byDay[day].length}</span></div>
<div class="day-items">${items}</div>
</div>`;
}).join('');
// Click a row (not the trash button) → open in the right pane.
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
if (e.target.closest('[data-del]')) return;
openRecording(+it.dataset.open);
});
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.del);
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del;
if (!confirm(`${name} wirklich löschen?\nDie Aufnahme, das Transkript und die Analyse werden unwiderruflich entfernt.`)) return;
await api.del('/api/recordings/' + b.dataset.del);
if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '<div class="detail-empty muted">← Aufnahme wählen</div>'; }
state.lastListSig = null; refreshRecList();
});
listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => {
const day = h.dataset.day;
if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day);
h.closest('.day-group').classList.toggle('collapsed');
});
scroller.scrollTop = savedTop; // keep the list where the user had it
ensureAnaTicker();
scheduleNext();
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
}
async function openRecording(id) {
state.selectedId = id;
const detail = document.querySelector('#detail');
if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); }
// Highlight the selected row without rebuilding the list (keeps it stable).
document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id));
detail.innerHTML = '<div class="card">Lade…</div>';
const r = await api.get('/api/recordings/' + id);
const pending = r.analysis_status === 'pending';
const errored = r.analysis_status === 'error';
const transcribing = r.status === 'transcribing';
const transcribeFailed = r.status === 'error';
const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3
const spkOrder = Object.keys(scls); // first-appearance order
const names = r.speaker_names || {}; // rawSpeaker -> name
const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1));
detail.innerHTML = `
<div class="card">
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
<div class="analysis">
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
${transcribing ? '<div class="muted">📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).</div>' : ''}
${transcribeFailed ? `<div class="err-inline">Transkription fehlgeschlagen. <button class="btn" id="retranscribe">Neu transkribieren</button></div>` : ''}
${(pending && !transcribing && !transcribeFailed) ? '<div class="muted">🧠 KI-Analyse läuft… (NPU, ~30-90s)</div>' : ''}
${(errored && !transcribeFailed) ? `<div class="err-inline">KI-Analyse fehlgeschlagen. <button class="btn" id="reanalyze">Erneut versuchen</button></div>` : ''}
${r.summary ? `<h3>Zusammenfassung</h3><p class="summary">${escapeHtml(r.summary)}</p>` : ''}
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}
${(r.todos && r.todos.length) ? `<h3>ToDos</h3><ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : ''}
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
</div>
${spkOrder.length ? `
<h3>Sprecher <span class="muted" style="font-weight:400">(Namen vergeben → App lernt die Stimme)</span></h3>
<div class="spk-edit">${spkOrder.map((sp, i) => `
<div class="spk-row ${scls[sp]}">
<span class="spk-dot"></span>
<input class="spk-name" data-sp="${sp}" value="${escapeHtml(names[sp] || '')}" placeholder="${escapeHtml(spkLabel(sp, i))}">
<label class="spk-learn-l"><input type="checkbox" class="spk-learn" checked> als Stimme lernen</label>
<button class="btn small spk-save" data-sp="${sp}">Speichern</button>
</div>`).join('')}
</div>` : ''}
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<h3>Original-Transkript</h3>
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
</div>`;
const reBtn = detail.querySelector('#reanalyze');
if (reBtn) reBtn.onclick = async () => {
// Re-runs speaker separation every time: empty = automatic detection (now
// consolidated, so no more "14 speakers" for a 2-person call), a number pins
// the count. Cancel aborts.
const ans = window.prompt('Sprecher neu erkennen?\n(leer = automatisch erkennen · Zahl = feste Anzahl vorgeben)', '');
if (ans === null) return;
const numSpeakers = parseInt(ans, 10);
const body = numSpeakers > 0 ? { numSpeakers } : {};
reBtn.disabled = true; reBtn.textContent = 'trennt neu…';
try { await api.post('/api/recordings/' + id + '/reanalyze', body); setTimeout(() => openRecording(id), 1500); }
catch (e) { toast(e.message, true); }
};
const rtBtn = detail.querySelector('#retranscribe');
if (rtBtn) rtBtn.onclick = async () => {
rtBtn.disabled = true; rtBtn.textContent = 'startet…';
try { await api.post('/api/recordings/' + id + '/retranscribe', {}); setTimeout(() => openRecording(id), 1500); }
catch (e) { toast(e.message, true); rtBtn.disabled = false; }
};
ensureAnaTicker();
// Poll while transcription OR the NPU analysis is still running, then refresh.
if (pending || transcribing) setTimeout(() => { const d = document.querySelector('#detail'); if (d && d.querySelector('.analysis')) openRecording(id); }, 5000);
const audio = detail.querySelector('#player');
const spans = [...detail.querySelectorAll('.w')];
spans.forEach(s => s.onclick = () => { audio.currentTime = (+s.dataset.start) / 1000 + 0.001; audio.play(); });
// Whisper (batched) word timestamps run slightly AHEAD of the audio, so a
// pure [start,end] match lit the NEXT word ("eine Zeile weiter") before it was
// spoken. Compensate with a fixed lead offset and highlight the LAST word whose
// start has passed (stays lit through inter-word gaps → no flicker).
// Tune SYNC_MS if the marker still leads (raise) or lags (lower).
const SYNC_MS = 250;
audio.ontimeupdate = () => {
const t = audio.currentTime * 1000 - SYNC_MS;
let active = null;
for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; }
spans.forEach(s => s.classList.toggle('active', s === active));
if (active) { const r2 = active.getBoundingClientRect(); if (r2.top < 90 || r2.bottom > innerHeight - 40) active.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
};
// Rename a speaker + (optionally) learn their voice into the DB.
detail.querySelectorAll('.spk-save').forEach(btn => {
btn.onclick = async () => {
const row = btn.closest('.spk-row');
const name = row.querySelector('.spk-name').value.trim();
const enroll = row.querySelector('.spk-learn').checked;
btn.disabled = true; btn.textContent = '…';
try {
const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: btn.dataset.sp, name, enroll });
toast(res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert'));
openRecording(id);
} catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; }
};
});
// Spacebar toggles play/pause of the open recording (unless typing in a field).
if (state.spaceHandler) document.removeEventListener('keydown', state.spaceHandler);
state.spaceHandler = (e) => {
if (e.code !== 'Space' && e.key !== ' ') return;
const t = e.target, tag = (t.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return;
const a = document.querySelector('#player');
if (!a) return;
e.preventDefault();
if (a.paused) a.play(); else a.pause();
};
document.addEventListener('keydown', state.spaceHandler);
// Two-pane layout: scroll the detail pane to its top. On a narrow (stacked)
// screen also bring the detail into view since it sits below the list.
const pane = detail.closest('.rec-detail-pane');
if (pane) pane.scrollTo({ top: 0, behavior: 'smooth' });
if (window.matchMedia('(max-width: 800px)').matches) detail.scrollIntoView({ behavior: 'smooth' });
}
function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3.
function speakerClasses(words) {
const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];
for (const w of words) { if (w.speaker && !(w.speaker in m)) { m[w.speaker] = palette[i % palette.length]; i++; } }
return m;
}
// Render the raw transcript as speaker turns with a NAME header per turn. Each
// word stays its own .w span (click-to-seek + active highlight still work).
function renderTurns(words, names, scls, order) {
if (!words || !words.length) return '<span class="muted">Kein Transkript.</span>';
const idx = {}; order.forEach((s, i) => idx[s] = i);
const label = sp => (names && names[sp]) || (sp ? 'Sprecher ' + ((idx[sp] ?? 0) + 1) : '');
let html = '', cur = '\0', buf = [];
const flush = () => {
if (!buf.length) return;
const nm = label(cur === '\0' ? null : cur);
html += `<div class="turn ${cur && cur !== '\0' ? (scls[cur] || '') : ''}">${nm ? `<b class="spk">${escapeHtml(nm)}</b> ` : ''}<span class="wt">${buf.join(' ')}</span></div>`;
buf = [];
};
for (const w of words) {
const sp = w.speaker || null;
if (sp !== cur) { flush(); cur = sp; }
buf.push(`<span class="w" data-start="${w.t_start_ms}" data-end="${w.t_end_ms}">${escapeHtml(w.word)}</span>`);
}
flush();
return html;
}
// Render "Name: Text" lines as a colour-coded dialogue (speakers inferred by
// the LLM from context — not acoustic).
function renderDialog(text) {
const lines = (text || '').split(/\n+/).map(l => l.trim()).filter(Boolean);
const colors = {}; let ci = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];
return lines.map(l => {
const m = l.match(/^([^:]{1,30}):\s*(.*)$/);
if (!m) return `<div class="turn"><span>${escapeHtml(l)}</span></div>`;
const spk = m[1].trim();
if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; }
return `<div class="turn ${colors[spk]}"><b class="spk">${escapeHtml(spk)}</b> <span>${escapeHtml(m[2])}</span></div>`;
}).join('');
}
function analysisBadge(st) {
if (st === 'pending') return '<span class="badge warn">🧠 Analyse…</span>';
if (st === 'error') return '<span class="badge err">KI-Fehler</span>';
if (st === 'done') return '<span class="badge ok">KI ✓</span>';
return '';
}
render();
+106
View File
@@ -0,0 +1,106 @@
:root {
--bg: #0e1116; --card: #171c24; --line: #262d38; --fg: #e7edf5; --muted: #8a97a8;
--accent: #4f8cff; --ok: #2ecc71; --warn: #f0b429; --danger: #ff5b5b;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif; }
#app { max-width: 860px; margin: 0 auto; padding: 16px; }
#app.wide { max-width: 1180px; }
header h1 { margin: 0 0 4px; font-size: 22px; }
header .sub { font-size: 12px; color: var(--muted); font-weight: 400; }
nav { display: flex; gap: 8px; margin: 14px 0; }
.tab { background: var(--card); color: var(--muted); border: 1px solid var(--line); padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 14px; }
.tab.active { color: #fff; border-color: var(--accent); background: #1c2735; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.grow { flex: 1; }
.btn { background: #222a35; color: var(--fg); border: 1px solid var(--line); border-radius: 9px; padding: 8px 14px; cursor: pointer; font-size: 14px; }
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.danger { background: #3a2224; border-color: #5a2f31; color: var(--danger); }
.btn.big { font-size: 17px; padding: 14px 22px; border-radius: 12px; }
.btn:disabled { opacity: .6; cursor: default; }
.lbl { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.input, select { width: 100%; background: #0f141b; color: var(--fg); border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; font-size: 14px; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item { display: flex; gap: 10px; align-items: center; background: #0f141b; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; background: #222a35; color: var(--muted); }
.badge.ok { background: #14351f; color: var(--ok); }
.badge.warn { background: #352c14; color: var(--warn); }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, monospace; font-size: 12px; }
.err { color: var(--danger); }
.rec-panel { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 22px 0 8px; }
.timer { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 600; }
h2 { margin: 0 0 12px; }
audio { width: 100%; margin-bottom: 10px; }
.hint { font-size: 12px; color: var(--muted); margin-bottom: 10px; }
.transcript { line-height: 2.1; font-size: 16px; }
.transcript .w { cursor: pointer; padding: 1px 2px; border-radius: 4px; transition: background .1s; }
.transcript .w:hover { background: #24303f; }
.transcript .w.active { background: var(--accent); color: #fff; }
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #1c2735; border: 1px solid var(--accent); color: #fff; padding: 10px 18px; border-radius: 10px; z-index: 99; max-width: 90%; }
.toast.err { border-color: var(--danger); }
/* LLM analysis */
.summary-snip{font-size:.85rem;color:#9fb3c8;margin-top:4px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.analysis{margin:10px 0;padding:10px;background:#111a24;border-radius:8px}
.analysis h3{margin:8px 0 4px;font-size:.9rem;color:#cbd5e1}
.summary{margin:0 0 6px;line-height:1.5;white-space:pre-line}
.todos{margin:0;padding-left:20px}
.todos li{margin:3px 0}
.err-inline{color:#f87171;margin-bottom:6px}
.btn.small{font-size:.8rem;padding:3px 8px}
.hint{font-size:.8rem;color:#8aa}
.parts{margin:0;padding-left:20px}.parts li{margin:3px 0}
.clean{line-height:1.55;background:#0e1620;padding:8px 10px;border-radius:8px;margin:0 0 4px}
.dialog{display:flex;flex-direction:column;gap:6px;margin-bottom:4px}
.turn{padding:6px 10px;border-radius:8px;background:#0e1620;line-height:1.5}
.turn .spk{margin-right:6px}
.turn.sp0{border-left:3px solid #38bdf8}.turn.sp0 .spk{color:#38bdf8}
.turn.sp1{border-left:3px solid #f472b6}.turn.sp1 .spk{color:#f472b6}
.turn.sp2{border-left:3px solid #a3e635}.turn.sp2 .spk{color:#a3e635}
.turn.sp3{border-left:3px solid #fbbf24}.turn.sp3 .spk{color:#fbbf24}
.w.sp0{color:#7dd3fc}.w.sp1{color:#f9a8d4}.w.sp2{color:#bef264}.w.sp3{color:#fcd34d}
.w.active{background:#334155;color:#fff}
/* speaker name editor (rename + learn voiceprint) */
.spk-edit{display:flex;flex-direction:column;gap:6px;margin:4px 0 8px}
.spk-row{display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:8px;background:#0e1620;border-left:3px solid #334155}
.spk-row.sp0{border-left-color:#38bdf8}.spk-row.sp1{border-left-color:#f472b6}.spk-row.sp2{border-left-color:#a3e635}.spk-row.sp3{border-left-color:#fbbf24}
.spk-dot{width:10px;height:10px;border-radius:50%;background:#64748b;flex:none}
.spk-row.sp0 .spk-dot{background:#38bdf8}.spk-row.sp1 .spk-dot{background:#f472b6}.spk-row.sp2 .spk-dot{background:#a3e635}.spk-row.sp3 .spk-dot{background:#fbbf24}
.spk-name{flex:1;min-width:80px;padding:5px 8px;border-radius:6px;border:1px solid #334155;background:#0b1220;color:#e2e8f0}
.spk-learn-l{display:flex;align-items:center;gap:4px;font-size:12px;color:#94a3b8;white-space:nowrap}
.ana-time{font-variant-numeric:tabular-nums;font-weight:600}
.ana-time.live{color:#4ea1ff}
.ana-line{margin:2px 0 8px}
/* ---- Recordings split layout: list (left) + detail (right) ---- */
.rec-layout{display:flex;gap:14px;align-items:flex-start}
.rec-list-pane{flex:0 0 340px;position:sticky;top:12px;max-height:calc(100vh - 90px);overflow-y:auto;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:10px}
.rec-detail-pane{flex:1 1 auto;min-width:0}
.rec-detail-pane .card{margin-bottom:0}
.detail-empty{padding:40px 16px;text-align:center;border:1px dashed var(--line);border-radius:14px}
/* date groups */
.day-group{margin-bottom:6px}
.day-head{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;padding:6px 4px;user-select:none}
.day-head:hover{color:var(--fg)}
.day-caret{display:inline-block;transition:transform .15s;font-size:10px}
.day-group.collapsed .day-caret{transform:rotate(-90deg)}
.day-group.collapsed .day-items{display:none}
.day-count{margin-left:auto;background:#222a35;border-radius:20px;padding:1px 8px;font-size:11px}
.day-items{display:flex;flex-direction:column;gap:6px}
/* list rows become clickable cards; selected row highlighted */
.item.rec{cursor:pointer;transition:border-color .1s,background .1s}
.item.rec:hover{border-color:var(--accent)}
.item.rec.sel{border-color:var(--accent);background:#1c2735}
.btn.icon{padding:6px 9px;flex:none}
@media(max-width:800px){
#app.wide{max-width:860px}
.rec-layout{flex-direction:column}
.rec-list-pane{flex:none;width:100%;position:static;max-height:340px}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: true,
proxy: {
'/api': 'http://localhost:3000',
'/media': 'http://localhost:3000'
}
},
build: { outDir: 'dist', emptyOutDir: true }
});