implement basic app structure, add mockup GUI
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# LLM API Configuration (OpenAI-compatible endpoint)
|
||||||
|
LLM_API_KEY=your_llm_api_key_here
|
||||||
|
LLM_API_BASE=http://localhost:11434/v1
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -168,3 +168,5 @@ cython_debug/
|
|||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
uv.lock
|
||||||
|
app_database/
|
||||||
@@ -14,6 +14,13 @@ Designed for operation in forensic-grade, air-gapped environments, the system en
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GUI Draft
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
***Work in progress***
|
||||||
|
*This is a mockup how the GUI would look, drafted with Claude.*
|
||||||
|
|
||||||
## SYSTEM ARCHITECTURE & DESIGN PHILOSOPHY
|
## SYSTEM ARCHITECTURE & DESIGN PHILOSOPHY
|
||||||
|
|
||||||
The design of Factum-Notes prioritizes integrity, low-latency input, and deployment flexibility.
|
The design of Factum-Notes prioritizes integrity, low-latency input, and deployment flexibility.
|
||||||
|
|||||||
37
pyproject.toml
Normal file
37
pyproject.toml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[project]
|
||||||
|
name = "factum"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Forensic note-taking application which utilizes PGP signatures on timestamped notes to ensure maximum integrity."
|
||||||
|
authors = [
|
||||||
|
{name = "Mario Stöckl", email = "mstoeck3@hs-mittweida.de"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = "==3.13.*"
|
||||||
|
license = {text = "BSD-3-Clause"}
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"PySide6~=6.8.0",
|
||||||
|
"logging>=0.4.9.6",
|
||||||
|
"openai~=2.8.1",
|
||||||
|
"python-dotenv~=1.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/factum"]
|
||||||
|
|
||||||
|
[tool.uv.workspace]
|
||||||
|
members = [
|
||||||
|
"factum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
factum = "factum.main:main"
|
||||||
147
requirements.txt
Normal file
147
requirements.txt
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# This file was autogenerated by uv via the following command:
|
||||||
|
# uv export --format requirements-txt
|
||||||
|
-e .
|
||||||
|
annotated-types==0.7.0 \
|
||||||
|
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
|
||||||
|
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.12.0 \
|
||||||
|
--hash=sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0 \
|
||||||
|
--hash=sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# openai
|
||||||
|
certifi==2025.11.12 \
|
||||||
|
--hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
|
||||||
|
--hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||||
|
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||||
|
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||||
|
# via tqdm
|
||||||
|
distro==1.9.0 \
|
||||||
|
--hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
|
||||||
|
--hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
|
||||||
|
# via openai
|
||||||
|
h11==0.16.0 \
|
||||||
|
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
|
||||||
|
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||||
|
# via httpcore
|
||||||
|
httpcore==1.0.9 \
|
||||||
|
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
|
||||||
|
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
|
||||||
|
# via httpx
|
||||||
|
httpx==0.28.1 \
|
||||||
|
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||||
|
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||||
|
# via openai
|
||||||
|
idna==3.11 \
|
||||||
|
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
|
||||||
|
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
jiter==0.12.0 \
|
||||||
|
--hash=sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45 \
|
||||||
|
--hash=sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7 \
|
||||||
|
--hash=sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb \
|
||||||
|
--hash=sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1 \
|
||||||
|
--hash=sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de \
|
||||||
|
--hash=sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed \
|
||||||
|
--hash=sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6 \
|
||||||
|
--hash=sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87 \
|
||||||
|
--hash=sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b \
|
||||||
|
--hash=sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e \
|
||||||
|
--hash=sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3 \
|
||||||
|
--hash=sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9 \
|
||||||
|
--hash=sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4 \
|
||||||
|
--hash=sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf \
|
||||||
|
--hash=sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60 \
|
||||||
|
--hash=sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44 \
|
||||||
|
--hash=sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a \
|
||||||
|
--hash=sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c \
|
||||||
|
--hash=sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626
|
||||||
|
# via openai
|
||||||
|
logging==0.4.9.6 \
|
||||||
|
--hash=sha256:26f6b50773f085042d301085bd1bf5d9f3735704db9f37c1ce6d8b85c38f2417
|
||||||
|
# via factum
|
||||||
|
openai==2.8.1 \
|
||||||
|
--hash=sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463 \
|
||||||
|
--hash=sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f
|
||||||
|
# via factum
|
||||||
|
pydantic==2.12.5 \
|
||||||
|
--hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \
|
||||||
|
--hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d
|
||||||
|
# via openai
|
||||||
|
pydantic-core==2.41.5 \
|
||||||
|
--hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \
|
||||||
|
--hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \
|
||||||
|
--hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \
|
||||||
|
--hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \
|
||||||
|
--hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \
|
||||||
|
--hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \
|
||||||
|
--hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \
|
||||||
|
--hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \
|
||||||
|
--hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \
|
||||||
|
--hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \
|
||||||
|
--hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \
|
||||||
|
--hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \
|
||||||
|
--hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \
|
||||||
|
--hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \
|
||||||
|
--hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e
|
||||||
|
# via pydantic
|
||||||
|
pyside6==6.8.3 \
|
||||||
|
--hash=sha256:31f390c961b54067ae41360e5ea3b340ce0e0e5feadea2236c28226d3b37edcc \
|
||||||
|
--hash=sha256:5bf5153cab9484629315f57c56a9ad4b7d075b4dd275f828f7549abf712c590b \
|
||||||
|
--hash=sha256:722dc0061d8ef6dbb8c0b99864f21e83a5b49ece1ecb2d0b890840d969e1e461 \
|
||||||
|
--hash=sha256:8e53e2357bfbdee1fa86c48312bf637460a2c26d49e7af0b3fae2e179ccc7052
|
||||||
|
# via factum
|
||||||
|
pyside6-addons==6.8.3 \
|
||||||
|
--hash=sha256:67548f6db11f4e1b7e4b6efd9c3fc2e8d275188a7b2feac388961128572a6955 \
|
||||||
|
--hash=sha256:6983d3b01fad53637bad5360930d5923509c744cc39704f9c1190eb9934e33da \
|
||||||
|
--hash=sha256:7949a844a40ee10998eb2734e2c06c4c7182dfcd4c21cc4108a6b96655ebe59f \
|
||||||
|
--hash=sha256:ea46649e40b9e6ab11a0da2da054d3914bff5607a5882885e9c3bc2eef200036
|
||||||
|
# via pyside6
|
||||||
|
pyside6-essentials==6.8.3 \
|
||||||
|
--hash=sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07 \
|
||||||
|
--hash=sha256:aa56c135db924ecfaf50088baf32f737d28027419ca5fee67c0c7141b29184e3 \
|
||||||
|
--hash=sha256:b4f4823f870b5bed477d6f7b6a3041839b859f70abfd703cf53208c73c2fe4cd \
|
||||||
|
--hash=sha256:fd57fa0c886ef99b3844173322c0023ec77cc946a0c9a0cdfbc2ac5c511053c1
|
||||||
|
# via
|
||||||
|
# pyside6
|
||||||
|
# pyside6-addons
|
||||||
|
python-dotenv==1.2.1 \
|
||||||
|
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
|
||||||
|
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
|
||||||
|
# via factum
|
||||||
|
shiboken6==6.8.3 \
|
||||||
|
--hash=sha256:295a003466ca2cccf6660e2f2ceb5e6cef4af192a48a196a32d46b6f0c9ec5cb \
|
||||||
|
--hash=sha256:2b1a41348102952d2a5fbf3630bddd4d44112e18058b5e4cf505e51f2812429d \
|
||||||
|
--hash=sha256:483efc7dd53c69147b8a8ade71f7619c79ffc683efcb1dc4f4cb6c40bb23d29b \
|
||||||
|
--hash=sha256:bca3a94513ce9242f7d4bbdca902072a1631888e0aa3a8711a52cc5dbe93588f
|
||||||
|
# via
|
||||||
|
# pyside6
|
||||||
|
# pyside6-addons
|
||||||
|
# pyside6-essentials
|
||||||
|
sniffio==1.3.1 \
|
||||||
|
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
|
||||||
|
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
|
||||||
|
# via openai
|
||||||
|
tqdm==4.67.1 \
|
||||||
|
--hash=sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2 \
|
||||||
|
--hash=sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2
|
||||||
|
# via openai
|
||||||
|
typing-extensions==4.15.0 \
|
||||||
|
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
||||||
|
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
||||||
|
# via
|
||||||
|
# openai
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.2 \
|
||||||
|
--hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
|
||||||
|
--hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
|
||||||
|
# via pydantic
|
||||||
844
resources/gui-mockup.jsx
Normal file
844
resources/gui-mockup.jsx
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ChevronRight, ChevronDown, Pin, X, Hash, User, Check, Send, Filter, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function FactumNotesMockup() {
|
||||||
|
const [selectedCase, setSelectedCase] = useState('CASE-2025-001');
|
||||||
|
const [selectedEvidence, setSelectedEvidence] = useState('HDD-001');
|
||||||
|
const [activeCase, setActiveCase] = useState('CASE-2025-001');
|
||||||
|
const [activeEvidence, setActiveEvidence] = useState('HDD-001');
|
||||||
|
const [expandedCases, setExpandedCases] = useState({ 'CASE-2025-001': true });
|
||||||
|
const [noteInput, setNoteInput] = useState('');
|
||||||
|
const [showGoals, setShowGoals] = useState(true);
|
||||||
|
const [showIocs, setShowIocs] = useState(true);
|
||||||
|
const [llmActive, setLlmActive] = useState(false);
|
||||||
|
const [llmInput, setLlmInput] = useState('');
|
||||||
|
const [llmMessages, setLlmMessages] = useState([
|
||||||
|
{ role: 'assistant', content: 'Ready to assist with analysis. Ask me about timeline correlation, IoC enrichment, or next investigation steps.' }
|
||||||
|
]);
|
||||||
|
const [filterTag, setFilterTag] = useState(null);
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
const cases = {
|
||||||
|
'CASE-2025-001': {
|
||||||
|
name: 'Financial Fraud Investigation',
|
||||||
|
evidences: ['HDD-001', 'USB-STICK-001', 'MEMORY-DUMP-001']
|
||||||
|
},
|
||||||
|
'CASE-2024-089': {
|
||||||
|
name: 'Corporate Espionage',
|
||||||
|
evidences: ['LAPTOP-001', 'PHONE-BACKUP-001']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
caseId: 'CASE-2025-001',
|
||||||
|
evidenceId: 'HDD-001',
|
||||||
|
timestamp: '2025-12-22 14:32:15 UTC',
|
||||||
|
content: 'Initial analysis of disk image reveals potential data exfiltration. Found suspicious PowerShell script at C:\\Users\\john.doe\\AppData\\Local\\Temp\\export.ps1. Script contains Base64-encoded payload attempting to connect to 192.168.45.123:4444 and download file from https://malicious-domain.com/payload.exe. MD5 hash of script: 5d41402abc4b2a76b9719d911017c592',
|
||||||
|
tags: ['initial-analysis', 'powershell', 'network-activity'],
|
||||||
|
signatures: ['Alice Johnson', 'Bob Smith'],
|
||||||
|
iocs: ['192.168.45.123:4444', 'https://malicious-domain.com/payload.exe', '5d41402abc4b2a76b9719d911017c592'],
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
caseId: 'CASE-2025-001',
|
||||||
|
evidenceId: 'HDD-001',
|
||||||
|
timestamp: '2025-12-22 15:45:02 UTC',
|
||||||
|
content: 'Registry analysis shows persistence mechanism established via Run key. Malware creates scheduled task named "SystemUpdate" executing C:\\Windows\\Temp\\svchost.exe every 30 minutes. SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||||
|
tags: ['persistence', 'registry', 'scheduled-task'],
|
||||||
|
signatures: ['Alice Johnson'],
|
||||||
|
iocs: ['C:\\Windows\\Temp\\svchost.exe', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'],
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
caseId: 'CASE-2025-001',
|
||||||
|
evidenceId: 'HDD-001',
|
||||||
|
timestamp: '2025-12-22 16:20:38 UTC',
|
||||||
|
content: 'Browser history extraction complete. User visited cryptocurrency exchange wallet at 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa multiple times between Dec 15-20. Timeline correlates with unauthorized transfers.',
|
||||||
|
tags: ['timeline', 'browser-history', 'cryptocurrency'],
|
||||||
|
signatures: ['Alice Johnson'],
|
||||||
|
iocs: ['1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'],
|
||||||
|
signed: true,
|
||||||
|
pinned: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
caseId: 'CASE-2025-001',
|
||||||
|
evidenceId: 'USB-STICK-001',
|
||||||
|
timestamp: '2025-12-22 17:10:22 UTC',
|
||||||
|
content: 'USB device analysis reveals autorun.inf configured to execute malicious payload. Device contains encrypted archive with password hint referencing internal project codename.',
|
||||||
|
tags: ['usb-analysis', 'autorun', 'encryption'],
|
||||||
|
signatures: ['Bob Smith'],
|
||||||
|
iocs: [],
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
caseId: 'CASE-2024-089',
|
||||||
|
evidenceId: 'LAPTOP-001',
|
||||||
|
timestamp: '2025-12-21 10:15:00 UTC',
|
||||||
|
content: 'Corporate laptop shows evidence of unauthorized remote access tool installation. RustDesk client configured with external server 203.0.113.50:21116',
|
||||||
|
tags: ['remote-access', 'corporate', 'initial-analysis'],
|
||||||
|
signatures: ['Alice Johnson'],
|
||||||
|
iocs: ['203.0.113.50:21116'],
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
caseId: 'CASE-2025-001',
|
||||||
|
evidenceId: null, // Case-level note
|
||||||
|
timestamp: '2025-12-22 13:00:00 UTC',
|
||||||
|
content: 'Case opened: Financial fraud investigation involving potential cryptocurrency theft. Initial evidence suggests internal employee involvement based on access patterns.',
|
||||||
|
tags: ['case-overview', 'fraud', 'cryptocurrency'],
|
||||||
|
signatures: ['Alice Johnson'],
|
||||||
|
iocs: [],
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
caseId: 'CASE-2025-001',
|
||||||
|
evidenceId: null, // Case-level note
|
||||||
|
timestamp: '2025-12-22 18:00:00 UTC',
|
||||||
|
content: 'Daily summary: Evidence from multiple devices shows coordinated exfiltration attempt. Recommend expanding investigation to include network logs and employee workstation forensics.',
|
||||||
|
tags: ['summary', 'coordination'],
|
||||||
|
signatures: ['Alice Johnson'],
|
||||||
|
iocs: [],
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [showNestedNotes, setShowNestedNotes] = useState(true);
|
||||||
|
|
||||||
|
const [pinnedNotes, setPinnedNotes] = useState([
|
||||||
|
{
|
||||||
|
id: 'pin-1',
|
||||||
|
content: 'CRITICAL: Check all PowerShell execution logs between Dec 15-20',
|
||||||
|
timestamp: '2025-12-22 13:00:00 UTC'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const investigativeGoals = [
|
||||||
|
'Identify initial infection vector and timeline',
|
||||||
|
'Map complete network of compromised systems',
|
||||||
|
'Determine data exfiltration extent and destination',
|
||||||
|
'Establish attribution indicators'
|
||||||
|
];
|
||||||
|
|
||||||
|
const toggleCase = (caseId) => {
|
||||||
|
setExpandedCases(prev => ({ ...prev, [caseId]: !prev[caseId] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractIocs = (text) => {
|
||||||
|
const iocs = [];
|
||||||
|
|
||||||
|
// IPv4 with port
|
||||||
|
const ipv4Pattern = /\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g;
|
||||||
|
const ipMatches = text.match(ipv4Pattern);
|
||||||
|
if (ipMatches) iocs.push(...ipMatches);
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
const urlPattern = /https?:\/\/[^\s]+/g;
|
||||||
|
const urlMatches = text.match(urlPattern);
|
||||||
|
if (urlMatches) iocs.push(...urlMatches);
|
||||||
|
|
||||||
|
// MD5
|
||||||
|
const md5Pattern = /\b[a-f0-9]{32}\b/gi;
|
||||||
|
const md5Matches = text.match(md5Pattern);
|
||||||
|
if (md5Matches) iocs.push(...md5Matches);
|
||||||
|
|
||||||
|
// SHA256
|
||||||
|
const sha256Pattern = /\b[a-f0-9]{64}\b/gi;
|
||||||
|
const sha256Matches = text.match(sha256Pattern);
|
||||||
|
if (sha256Matches) iocs.push(...sha256Matches);
|
||||||
|
|
||||||
|
// Bitcoin addresses (simplified)
|
||||||
|
const btcPattern = /\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b/g;
|
||||||
|
const btcMatches = text.match(btcPattern);
|
||||||
|
if (btcMatches) iocs.push(...btcMatches);
|
||||||
|
|
||||||
|
// File paths
|
||||||
|
const pathPattern = /[A-Z]:\\(?:[^\s\\]+\\)*[^\s\\]+\.[a-z]{2,4}/gi;
|
||||||
|
const pathMatches = text.match(pathPattern);
|
||||||
|
if (pathMatches) iocs.push(...pathMatches);
|
||||||
|
|
||||||
|
return [...new Set(iocs)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractTags = (text) => {
|
||||||
|
const tagPattern = /#([a-z0-9-]+)/gi;
|
||||||
|
const matches = text.match(tagPattern);
|
||||||
|
return matches ? [...new Set(matches.map(t => t.substring(1).toLowerCase()))] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNote = () => {
|
||||||
|
if (!noteInput.trim()) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||||
|
const iocs = extractIocs(noteInput);
|
||||||
|
const tags = extractTags(noteInput);
|
||||||
|
|
||||||
|
const newNote = {
|
||||||
|
id: Math.max(...notes.map(n => n.id), 0) + 1,
|
||||||
|
caseId: selectedCase,
|
||||||
|
evidenceId: selectedEvidence,
|
||||||
|
timestamp,
|
||||||
|
content: noteInput,
|
||||||
|
tags,
|
||||||
|
signatures: ['Alice Johnson'],
|
||||||
|
iocs,
|
||||||
|
signed: true,
|
||||||
|
pinned: false
|
||||||
|
};
|
||||||
|
|
||||||
|
setNotes([...notes, newNote]);
|
||||||
|
setNoteInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePin = (noteId) => {
|
||||||
|
setNotes(notes.map(note =>
|
||||||
|
note.id === noteId ? { ...note, pinned: !note.pinned } : note
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNote = (noteId) => {
|
||||||
|
setNotes(notes.filter(n => n.id !== noteId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendLlmMessage = () => {
|
||||||
|
if (!llmInput.trim()) return;
|
||||||
|
|
||||||
|
const userMsg = { role: 'user', content: llmInput };
|
||||||
|
setLlmMessages([...llmMessages, userMsg]);
|
||||||
|
|
||||||
|
// Simulate AI response
|
||||||
|
setTimeout(() => {
|
||||||
|
let response = '';
|
||||||
|
const input = llmInput.toLowerCase();
|
||||||
|
|
||||||
|
if (input.includes('timeline') || input.includes('chronology')) {
|
||||||
|
response = 'Based on the timestamps, the attack sequence appears to be: 1) Initial compromise via PowerShell script (14:32), 2) Persistence establishment through registry modification (15:45), 3) Data exfiltration to cryptocurrency wallet (16:20). Consider examining system logs between these timestamps for additional pivot points.';
|
||||||
|
} else if (input.includes('ioc') || input.includes('indicator')) {
|
||||||
|
response = 'I\'ve identified 6 unique IoCs across your notes. The IP address 192.168.45.123:4444 appears to be a C2 server. I recommend: 1) Checking if this IP appears in other evidence items, 2) Searching for the malicious domain in DNS logs, 3) Validating the file hashes against VirusTotal.';
|
||||||
|
} else if (input.includes('next') || input.includes('recommend')) {
|
||||||
|
response = 'Recommended next steps: 1) Analyze the PowerShell execution history for the timeframe Dec 15-20, 2) Extract and decrypt the USB archive if possible, 3) Cross-reference the cryptocurrency wallet address with blockchain explorers for transaction analysis, 4) Check network logs for communication with 192.168.45.123.';
|
||||||
|
} else {
|
||||||
|
response = 'I can help analyze your notes, correlate timelines, enrich IoCs, or suggest investigation steps. What specific aspect would you like assistance with?';
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMsg = { role: 'assistant', content: response };
|
||||||
|
setLlmMessages(prev => [...prev, aiMsg]);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
setLlmInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e, callback) => {
|
||||||
|
if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter notes based on selection and tag filter
|
||||||
|
const filteredNotes = notes.filter(note => {
|
||||||
|
const matchesCase = note.caseId === selectedCase;
|
||||||
|
const matchesTag = !filterTag || note.tags.includes(filterTag);
|
||||||
|
|
||||||
|
if (!matchesCase || !matchesTag) return false;
|
||||||
|
|
||||||
|
// If viewing a specific evidence, only show notes from that evidence
|
||||||
|
if (selectedEvidence) {
|
||||||
|
return note.evidenceId === selectedEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If viewing case-level (no evidence selected)
|
||||||
|
// Show case-level notes always
|
||||||
|
if (!note.evidenceId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show evidence notes only if nested toggle is on
|
||||||
|
return showNestedNotes;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
// Sort chronologically by timestamp
|
||||||
|
return new Date(a.timestamp) - new Date(b.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all unique tags from filtered notes
|
||||||
|
const allTags = [...new Set(filteredNotes.flatMap(note => note.tags))];
|
||||||
|
|
||||||
|
// Get extracted IoCs from filtered notes and organize by type
|
||||||
|
const iocsByType = filteredNotes.reduce((acc, note) => {
|
||||||
|
note.iocs.forEach(ioc => {
|
||||||
|
let type = 'Unknown';
|
||||||
|
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(ioc)) type = 'IPv4';
|
||||||
|
else if (/^https?:\/\//.test(ioc)) type = 'URL';
|
||||||
|
else if (/^[a-f0-9]{32}$/i.test(ioc)) type = 'MD5';
|
||||||
|
else if (/^[a-f0-9]{64}$/i.test(ioc)) type = 'SHA256';
|
||||||
|
else if (/^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(ioc)) type = 'Bitcoin';
|
||||||
|
else if (/^[A-Z]:\\/.test(ioc)) type = 'File Path';
|
||||||
|
|
||||||
|
if (!acc[type]) {
|
||||||
|
acc[type] = [];
|
||||||
|
}
|
||||||
|
if (!acc[type].find(item => item === ioc)) {
|
||||||
|
acc[type].push(ioc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Get pinned notes from the currently selected case (not filtered by evidence)
|
||||||
|
const displayPinnedNotes = notes.filter(n => n.pinned && n.caseId === selectedCase);
|
||||||
|
|
||||||
|
// State for IoC tree expansion
|
||||||
|
const [expandedIocTypes, setExpandedIocTypes] = useState({});
|
||||||
|
|
||||||
|
const toggleIocType = (type) => {
|
||||||
|
setExpandedIocTypes(prev => ({ ...prev, [type]: !prev[type] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightIocs = (text, iocs) => {
|
||||||
|
if (!iocs || iocs.length === 0) return text;
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
const parts = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
// Sort IOCs by their position in text
|
||||||
|
const positions = [];
|
||||||
|
iocs.forEach(ioc => {
|
||||||
|
const index = result.indexOf(ioc);
|
||||||
|
if (index !== -1) {
|
||||||
|
positions.push({ ioc, index });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
positions.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
positions.forEach(({ ioc, index }) => {
|
||||||
|
if (index >= lastIndex) {
|
||||||
|
parts.push(result.substring(lastIndex, index));
|
||||||
|
parts.push(<span key={index} className="bg-amber-100 text-amber-900 px-1 rounded font-mono text-sm border border-amber-300">{ioc}</span>);
|
||||||
|
lastIndex = index + ioc.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
parts.push(result.substring(lastIndex));
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-stone-50 text-stone-800 font-mono">
|
||||||
|
{/* Menu Bar */}
|
||||||
|
<div className="bg-amber-900 border-b border-amber-800 px-4 py-2 flex items-center space-x-6">
|
||||||
|
<span className="text-amber-50 font-bold text-lg">FACTUM-NOTES</span>
|
||||||
|
<button className="text-amber-100 hover:text-amber-50 transition-colors">File</button>
|
||||||
|
<button className="text-amber-100 hover:text-amber-50 transition-colors">View</button>
|
||||||
|
<button className="text-amber-100 hover:text-amber-50 transition-colors">Settings</button>
|
||||||
|
<button className="text-amber-100 hover:text-amber-50 transition-colors">Help</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Left Sidebar - Case/Evidence Tree */}
|
||||||
|
<div className="w-72 bg-stone-100 border-r border-stone-300 flex flex-col">
|
||||||
|
<div className="p-3 border-b border-stone-300">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-stone-600 mb-1">Case Structure</h3>
|
||||||
|
<p className="text-xs text-stone-500 flex items-center">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-600 mr-1"></span> = Active for CLI notes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{Object.entries(cases).map(([caseId, caseData]) => (
|
||||||
|
<div key={caseId} className="mb-2">
|
||||||
|
<div
|
||||||
|
className={`flex items-center space-x-2 px-2 py-2 rounded cursor-pointer transition-colors ${
|
||||||
|
selectedCase === caseId && !selectedEvidence ? 'bg-amber-100 text-amber-900 border border-amber-300' : 'hover:bg-stone-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCase(caseId);
|
||||||
|
setSelectedEvidence(null);
|
||||||
|
toggleCase(caseId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedCases[caseId] ?
|
||||||
|
<ChevronDown className="w-4 h-4" /> :
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2 h-2 rounded-full cursor-pointer transition-colors ${
|
||||||
|
activeCase === caseId && !activeEvidence ? 'bg-green-600' : 'bg-stone-400 hover:bg-green-400'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveCase(caseId);
|
||||||
|
setActiveEvidence(null);
|
||||||
|
}}
|
||||||
|
title={activeCase === caseId && !activeEvidence ? "Active for CLI notes" : "Set as active for CLI notes"}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold flex-1">{caseId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedCases[caseId] && (
|
||||||
|
<div className="ml-6 mt-1 space-y-1">
|
||||||
|
<div className="text-xs text-stone-600 mb-2">{caseData.name}</div>
|
||||||
|
{caseData.evidences.map(evidence => (
|
||||||
|
<div
|
||||||
|
key={evidence}
|
||||||
|
className={`px-2 py-1.5 rounded text-sm cursor-pointer transition-colors flex items-center ${
|
||||||
|
selectedEvidence === evidence ? 'bg-amber-50 text-amber-900 border-l-2 border-amber-700' : 'hover:bg-stone-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCase(caseId);
|
||||||
|
setSelectedEvidence(evidence);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2 h-2 rounded-full mr-2 cursor-pointer transition-colors ${
|
||||||
|
activeCase === caseId && activeEvidence === evidence ? 'bg-green-600' : 'bg-stone-400 hover:bg-green-400'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveCase(caseId);
|
||||||
|
setActiveEvidence(evidence);
|
||||||
|
}}
|
||||||
|
title={activeCase === caseId && activeEvidence === evidence ? "Active for CLI notes" : "Set as active for CLI notes"}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{evidence}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-stone-300 space-y-2">
|
||||||
|
<button className="w-full px-3 py-2 bg-amber-700 hover:bg-amber-800 text-amber-50 rounded text-sm transition-colors">
|
||||||
|
+ New Case
|
||||||
|
</button>
|
||||||
|
<button className="w-full px-3 py-2 bg-red-100 hover:bg-red-200 text-red-800 border border-red-300 rounded text-sm transition-colors">
|
||||||
|
− Delete Case
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Central Content Area */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<div className="border-b border-stone-300 px-4 py-2 bg-stone-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Filter className="w-4 h-4 text-stone-600" />
|
||||||
|
<span className="text-xs text-stone-600">Filter by tag:</span>
|
||||||
|
<select
|
||||||
|
value={filterTag || ''}
|
||||||
|
onChange={(e) => setFilterTag(e.target.value || null)}
|
||||||
|
className="bg-white border border-stone-300 rounded px-2 py-1 text-xs text-stone-800 focus:outline-none focus:border-amber-700 focus:ring-1 focus:ring-amber-700"
|
||||||
|
>
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{allTags.map(tag => (
|
||||||
|
<option key={tag} value={tag}>#{tag}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{filterTag && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterTag(null)}
|
||||||
|
className="text-xs text-red-700 hover:text-red-800 transition-colors"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!selectedEvidence && (
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showNestedNotes}
|
||||||
|
onChange={(e) => setShowNestedNotes(e.target.checked)}
|
||||||
|
className="w-3 h-3 text-amber-700 focus:ring-amber-700 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-stone-700">Show nested evidence notes</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-stone-600">{filteredNotes.length} note{filteredNotes.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Display */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{filteredNotes.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-stone-500 text-sm">
|
||||||
|
No notes for this selection
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredNotes.map((note, idx) => {
|
||||||
|
// Determine if this is a nested note (evidence note when viewing case-level)
|
||||||
|
const isNested = !selectedEvidence && note.evidenceId;
|
||||||
|
|
||||||
|
// For tree structure, check if next note is also nested
|
||||||
|
const nextNote = filteredNotes[idx + 1];
|
||||||
|
const isLastNested = isNested && (!nextNote || !nextNote.evidenceId);
|
||||||
|
const treeSymbol = isNested ? (isLastNested ? '└──' : '├──') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={note.id} className={`bg-white border border-stone-300 rounded-lg p-4 hover:border-amber-400 hover:shadow-sm transition-all ${isNested ? 'ml-12' : ''}`}>
|
||||||
|
{/* Timestamp and Evidence Badge */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-2 flex-1">
|
||||||
|
{isNested && (
|
||||||
|
<>
|
||||||
|
<span className="text-stone-500 font-mono text-xs flex-shrink-0">{treeSymbol}</span>
|
||||||
|
<span className="text-xs font-semibold text-stone-700 bg-amber-100 px-2 py-1 rounded border border-amber-300 flex-shrink-0">
|
||||||
|
{note.evidenceId}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-amber-800 text-sm font-bold">{note.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Pin
|
||||||
|
className={`w-4 h-4 cursor-pointer transition-colors ${
|
||||||
|
note.pinned ? 'text-amber-700 fill-amber-700' : 'text-stone-400 hover:text-amber-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => togglePin(note.id)}
|
||||||
|
/>
|
||||||
|
<Trash2
|
||||||
|
className="w-4 h-4 text-stone-400 hover:text-red-700 cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Delete this note?')) {
|
||||||
|
deleteNote(note.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="text-sm text-stone-700 leading-relaxed mb-3">
|
||||||
|
{highlightIocs(note.content, note.iocs)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{note.tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-1 bg-blue-50 text-blue-800 rounded text-xs border border-blue-200 cursor-pointer hover:bg-blue-100 transition-colors"
|
||||||
|
onClick={() => setFilterTag(tag)}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signatures */}
|
||||||
|
<div className="flex items-center space-x-2 pt-2 border-t border-stone-200">
|
||||||
|
<Check className="w-3 h-3 text-green-700" />
|
||||||
|
<span className="text-xs text-stone-600">Signed by:</span>
|
||||||
|
{note.signatures.map((signer, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="group relative"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 text-green-700 cursor-help" />
|
||||||
|
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-stone-800 text-amber-50 text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
||||||
|
{signer}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="border-t border-stone-300 p-4 bg-stone-100">
|
||||||
|
<textarea
|
||||||
|
value={noteInput}
|
||||||
|
onChange={(e) => setNoteInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, addNote)}
|
||||||
|
placeholder="Enter forensic note... Use #tags for categorization. IoCs will be auto-extracted. (Ctrl+Enter to submit)"
|
||||||
|
className="w-full h-24 bg-white border border-stone-300 rounded px-3 py-2 text-sm text-stone-800 placeholder-stone-500 focus:outline-none focus:border-amber-700 focus:ring-1 focus:ring-amber-700 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-stone-600">IoCs will be auto-extracted | GPG signing enabled</span>
|
||||||
|
<button
|
||||||
|
onClick={addNote}
|
||||||
|
className="px-4 py-1.5 bg-amber-700 hover:bg-amber-800 text-amber-50 rounded text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={!noteInput.trim()}
|
||||||
|
>
|
||||||
|
Add Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar - Information Panel */}
|
||||||
|
<div className="w-80 bg-stone-100 border-l border-stone-300 flex flex-col overflow-y-auto">
|
||||||
|
{/* Investigative Goals - Always at top */}
|
||||||
|
<div className="border-b border-stone-300">
|
||||||
|
<div
|
||||||
|
className="p-3 bg-stone-200 flex items-center justify-between cursor-pointer hover:bg-stone-250 transition-colors"
|
||||||
|
onClick={() => setShowGoals(!showGoals)}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-stone-700">Investigative Goals</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
alert('Edit goals dialog would open here');
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-stone-300 rounded transition-colors"
|
||||||
|
title="Edit Goals"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{showGoals ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showGoals && (
|
||||||
|
<div className="p-3">
|
||||||
|
<ol className="space-y-2 text-xs text-stone-700">
|
||||||
|
{investigativeGoals.map((goal, idx) => (
|
||||||
|
<li key={idx} className="flex items-start">
|
||||||
|
<span className="text-amber-800 mr-2 font-semibold">{idx + 1}.</span>
|
||||||
|
<span>{goal}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned Notes */}
|
||||||
|
{displayPinnedNotes.length > 0 && (
|
||||||
|
<div className="border-b border-stone-300">
|
||||||
|
<div className="p-3 bg-stone-200">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-stone-700 flex items-center">
|
||||||
|
<Pin className="w-3 h-3 mr-2" />
|
||||||
|
Pinned Notes
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2 max-h-64 overflow-y-auto" style={{minHeight: '80px'}}>
|
||||||
|
{displayPinnedNotes.map(note => (
|
||||||
|
<div key={note.id} className="bg-green-50 border border-green-300 rounded p-3">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs text-green-800">{note.timestamp}</span>
|
||||||
|
<div className="text-xs text-stone-600 mt-1">
|
||||||
|
{note.caseId}{note.evidenceId ? ` > ${note.evidenceId}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
className="w-3 h-3 text-stone-500 hover:text-red-700 cursor-pointer transition-colors flex-shrink-0"
|
||||||
|
onClick={() => togglePin(note.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-stone-800 line-clamp-3">{note.content}</p>
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{note.tags.map(tag => (
|
||||||
|
<span key={tag} className="px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded text-xs border border-blue-200">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<div className="h-1 bg-stone-300 hover:bg-amber-600 cursor-ns-resize transition-colors" title="Drag to resize"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extracted IOCs */}
|
||||||
|
<div className="border-b border-stone-300">
|
||||||
|
<div
|
||||||
|
className="p-3 bg-stone-200 flex items-center justify-between cursor-pointer hover:bg-stone-250 transition-colors"
|
||||||
|
onClick={() => setShowIocs(!showIocs)}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-stone-700 flex items-center">
|
||||||
|
<Hash className="w-3 h-3 mr-2" />
|
||||||
|
Extracted IOCs ({Object.values(iocsByType).flat().length})
|
||||||
|
</h3>
|
||||||
|
{showIocs ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
</div>
|
||||||
|
{showIocs && (
|
||||||
|
<div className="p-3 space-y-1 max-h-64 overflow-y-auto" style={{minHeight: '100px'}}>
|
||||||
|
{Object.keys(iocsByType).length === 0 ? (
|
||||||
|
<p className="text-xs text-stone-500">No IoCs extracted yet</p>
|
||||||
|
) : (
|
||||||
|
Object.entries(iocsByType).map(([type, iocs]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-1 cursor-pointer hover:bg-stone-100 py-1 px-2 rounded transition-colors"
|
||||||
|
onClick={() => toggleIocType(type)}
|
||||||
|
>
|
||||||
|
<span className="text-stone-700 font-mono text-xs">
|
||||||
|
{expandedIocTypes[type] ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-amber-800">
|
||||||
|
{type} ({iocs.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedIocTypes[type] && (
|
||||||
|
<div className="ml-4 space-y-1">
|
||||||
|
{iocs.map((ioc, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-start space-x-1 py-1 px-2 hover:bg-amber-50 rounded cursor-pointer transition-colors"
|
||||||
|
onClick={() => alert(`Filter notes containing: ${ioc}`)}
|
||||||
|
title="Click to filter notes containing this IoC"
|
||||||
|
>
|
||||||
|
<span className="text-stone-500 font-mono text-xs flex-shrink-0">
|
||||||
|
{idx === iocs.length - 1 ? '└──' : '├──'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-stone-700 font-mono break-all">{ioc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<div className="h-1 bg-stone-300 hover:bg-amber-600 cursor-ns-resize transition-colors" title="Drag to resize"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LLM Interface */}
|
||||||
|
<div className="flex-1 flex flex-col" style={{minHeight: '200px'}}>
|
||||||
|
<div className="p-3 bg-stone-200 flex items-center justify-between">
|
||||||
|
<h3 className="text-xs uppercase tracking-wider text-stone-700">AI Assistant</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setLlmActive(!llmActive)}
|
||||||
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
||||||
|
llmActive ? 'bg-green-100 text-green-800 border border-green-300' : 'bg-stone-300 text-stone-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{llmActive ? 'ACTIVE' : 'INACTIVE'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{llmActive && (
|
||||||
|
<div className="flex-1 flex flex-col p-3 min-h-0">
|
||||||
|
{/* Quick Action Buttons */}
|
||||||
|
<div className="mb-3 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const assessMsg = { role: 'user', content: '[Assess Investigation] Compare current notes with investigative goals' };
|
||||||
|
setLlmMessages([...llmMessages, assessMsg]);
|
||||||
|
setTimeout(() => {
|
||||||
|
const response = { role: 'assistant', content: 'Based on your notes, you\'ve made progress on goals 1 and 3. Goal 2 (mapping compromised systems) needs attention - consider analyzing network logs from the malicious IP 192.168.45.123. Goal 4 (attribution) is still pending - the cryptocurrency wallet and PowerShell techniques could be researched for similar campaigns.' };
|
||||||
|
setLlmMessages(prev => [...prev, response]);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-blue-100 hover:bg-blue-200 text-blue-800 border border-blue-300 rounded text-xs transition-colors text-left flex items-center"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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 2m-6 9l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
Assess Investigation
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const suggestMsg = { role: 'user', content: '[Suggest Goals] Recommend investigation directions' };
|
||||||
|
setLlmMessages([...llmMessages, suggestMsg]);
|
||||||
|
setTimeout(() => {
|
||||||
|
const response = { role: 'assistant', content: 'Based on your findings, I suggest:\n1. Timeline all PowerShell executions and correlate with network connections\n2. Analyze the encrypted USB archive - password hint may lead to internal knowledge\n3. Search for similar Bitcoin wallet in other evidence items\n4. Research the hash 5d41402abc4b2a76b9719d911017c592 against threat intelligence databases\n\nWould you like me to add these to your investigative goals?' };
|
||||||
|
setLlmMessages(prev => [...prev, response]);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-green-100 hover:bg-green-200 text-green-800 border border-green-300 rounded text-xs transition-colors text-left flex items-center"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
Suggest Goals
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversation History */}
|
||||||
|
<div className="flex-1 bg-white border border-stone-300 rounded p-3 mb-2 overflow-y-auto text-xs space-y-2">
|
||||||
|
{llmMessages.map((msg, idx) => (
|
||||||
|
<div key={idx} className={`${msg.role === 'user' ? 'text-blue-800' : 'text-stone-700'}`}>
|
||||||
|
<span className="font-semibold">{msg.role === 'user' ? '👤 You' : '🤖 AI'}:</span> {msg.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Query Input */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={llmInput}
|
||||||
|
onChange={(e) => setLlmInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
sendLlmMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Ask the AI..."
|
||||||
|
className="flex-1 bg-white border border-stone-300 rounded px-2 py-1.5 text-xs text-stone-800 placeholder-stone-500 focus:outline-none focus:border-amber-700 focus:ring-1 focus:ring-amber-700"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendLlmMessage}
|
||||||
|
disabled={!llmInput.trim()}
|
||||||
|
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-800 text-amber-50 rounded text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Send className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Bar */}
|
||||||
|
<div className="bg-amber-900 border-t border-amber-800 px-4 py-2 flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<span className="text-amber-100">
|
||||||
|
Viewing: <span className="text-amber-50 font-semibold">{selectedCase}</span>
|
||||||
|
{selectedEvidence && <span className="text-amber-50 font-semibold"> / {selectedEvidence}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-100 flex items-center">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>
|
||||||
|
CLI Active: <span className="text-amber-50 font-semibold ml-1">{activeCase}</span>
|
||||||
|
{activeEvidence && <span className="text-amber-50 font-semibold"> / {activeEvidence}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-amber-100">
|
||||||
|
GPG Key: <span className="text-amber-200">alice.johnson@forensics.lab (4096R/A1B2C3D4)</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center text-green-200">
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Signing Enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
resources/gui-mockup.png
Normal file
BIN
resources/gui-mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
12
src/factum/__init__.py
Normal file
12
src/factum/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2025, mstoeck3
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This source code is licensed under the BSD-3-Clause license found in the
|
||||||
|
# LICENSE file in the root directory of this source tree.
|
||||||
|
#
|
||||||
|
"""factum-notes: """
|
||||||
|
|
||||||
|
__version__ = "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
20
src/factum/config.py
Normal file
20
src/factum/config.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import logging
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self):
|
||||||
|
env = dotenv_values(".env")
|
||||||
|
self.log_level_str = (env.get("LOG_LEVEL") or "DEBUG").upper()
|
||||||
|
|
||||||
|
self.level_map = {
|
||||||
|
"DEBUG": logging.DEBUG,
|
||||||
|
"INFO": logging.INFO,
|
||||||
|
"WARNING": logging.WARNING,
|
||||||
|
"ERROR": logging.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def log_level(self):
|
||||||
|
return self.level_map.get(self.log_level_str, logging.DEBUG)
|
||||||
|
|
||||||
|
settings = Config()
|
||||||
23
src/factum/main.py
Normal file
23
src/factum/main.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import logging
|
||||||
|
from factum.config import settings
|
||||||
|
from factum.services.db_service import DBService
|
||||||
|
from factum.ui.main_window import FactumWindow
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
class FactumApp:
|
||||||
|
def __init__(self, db_service: DBService):
|
||||||
|
self.app = QApplication([])
|
||||||
|
self.main_window = FactumWindow(db_service)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.main_window.show()
|
||||||
|
self.app.exec()
|
||||||
|
logging.info("Factum application is running.")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(level=settings.log_level)
|
||||||
|
logging.debug(f"Starting Factum with level: {settings.log_level_str}")
|
||||||
|
|
||||||
|
db_service = DBService()
|
||||||
|
app = FactumApp(db_service)
|
||||||
|
app.run()
|
||||||
0
src/factum/services/__init__.py
Normal file
0
src/factum/services/__init__.py
Normal file
27
src/factum/services/db_service.py
Normal file
27
src/factum/services/db_service.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DB_DIR = "app_database"
|
||||||
|
DB_FILE = os.path.join(DB_DIR, "factum.db")
|
||||||
|
|
||||||
|
class DBService:
|
||||||
|
def __init__(self):
|
||||||
|
logging.debug("Initializing DBService")
|
||||||
|
self.conn = self.connect_db()
|
||||||
|
|
||||||
|
def connect_db(self):
|
||||||
|
logging.debug(f"Trying to connect to database at {DB_FILE}")
|
||||||
|
if not os.path.exists(DB_DIR):
|
||||||
|
logging.debug(f"Database directory {DB_DIR} does not exist, creating...")
|
||||||
|
os.makedirs(DB_DIR, exist_ok=True)
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_FILE)
|
||||||
|
logging.info("Database connection established.")
|
||||||
|
return conn
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error(f"Database connection failed: {e}")
|
||||||
|
raise
|
||||||
1
src/factum/ui/__init__.py
Normal file
1
src/factum/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
45
src/factum/ui/main_window.py
Normal file
45
src/factum/ui/main_window.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QMenu, QHBoxLayout
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from factum import config
|
||||||
|
import factum.ui.stylesheet as ss
|
||||||
|
|
||||||
|
class FactumWindow(QMainWindow):
|
||||||
|
def __init__(self, db_service):
|
||||||
|
super().__init__()
|
||||||
|
self.db_service = db_service
|
||||||
|
self.config = config.settings
|
||||||
|
self.create_ui()
|
||||||
|
self.connect_ui_elements()
|
||||||
|
|
||||||
|
def create_ui(self):
|
||||||
|
self.setWindowTitle("Factum")
|
||||||
|
self.setGeometry(100, 100, 800, 600)
|
||||||
|
self.setStyleSheet(ss.main_window_style)
|
||||||
|
|
||||||
|
central_widget = QWidget()
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
|
||||||
|
self.main_layout = QHBoxLayout()
|
||||||
|
self.case_tree_layout = QVBoxLayout()
|
||||||
|
self.note_area_layout = QVBoxLayout()
|
||||||
|
self.info_widget_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
self.main_layout.addLayout(self.case_tree_layout)
|
||||||
|
self.main_layout.addLayout(self.note_area_layout)
|
||||||
|
self.main_layout.addLayout(self.info_widget_layout)
|
||||||
|
|
||||||
|
central_widget.setLayout(self.main_layout)
|
||||||
|
|
||||||
|
self.create_menu_bar()
|
||||||
|
def connect_ui_elements(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create_menu_bar(self):
|
||||||
|
menu_bar = self.menuBar()
|
||||||
|
menu_bar.setStyleSheet(ss.toolbar_style)
|
||||||
|
|
||||||
|
settings_menu = QMenu("&Settings", self)
|
||||||
|
menu_bar.addMenu(settings_menu)
|
||||||
|
|
||||||
|
about_menu = QMenu("&About", self)
|
||||||
|
menu_bar.addMenu(about_menu)
|
||||||
65
src/factum/ui/stylesheet.py
Normal file
65
src/factum/ui/stylesheet.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
main_window_style = """
|
||||||
|
QWidget {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
button_style = """
|
||||||
|
QPushButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #808080;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #6e6e6e;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #6e6e6e;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #5e5e5e;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
input_style = """
|
||||||
|
QLineEdit {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
QLineEdit:focus {
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
toolbar_style = """
|
||||||
|
QMenuBar::item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QMenuBar::item:selected {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
QMenu {
|
||||||
|
border: 2px solid #c0c0c0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
QMenu::item {
|
||||||
|
padding: 8px 24px 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
status_widget_style = """
|
||||||
|
|
||||||
|
"""
|
||||||
Reference in New Issue
Block a user