From ed1b06a1c2cf68ed8ea33302b3fc05d0286c2942 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 19 Nov 2025 22:12:24 +0000 Subject: [PATCH] Auto commit: 2025-11-19T22:12:24.592Z --- .perm_test_apache | 0 .perm_test_exec | 0 ai/__init__.py | 3 + ai/local_ai_api.py | 420 ++++++++++++++++++ config/__pycache__/__init__.cpython-311.pyc | Bin 159 -> 159 bytes config/__pycache__/settings.cpython-311.pyc | Bin 4210 -> 4210 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1143 -> 1143 bytes config/__pycache__/wsgi.cpython-311.pyc | Bin 679 -> 679 bytes core/__pycache__/__init__.cpython-311.pyc | Bin 157 -> 157 bytes core/__pycache__/admin.cpython-311.pyc | Bin 777 -> 460 bytes core/__pycache__/apps.cpython-311.pyc | Bin 524 -> 524 bytes core/__pycache__/forms.cpython-311.pyc | Bin 847 -> 1326 bytes core/__pycache__/models.cpython-311.pyc | Bin 1409 -> 1862 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 548 bytes core/__pycache__/views.cpython-311.pyc | Bin 2006 -> 2892 bytes core/admin.py | 9 +- core/forms.py | 26 +- .../0002_article_todoitem_delete_ticket.py | 35 ++ ...0003_todoitem_description_todoitem_tags.py | 23 + .../__pycache__/0001_initial.cpython-311.pyc | Bin 1660 -> 1660 bytes ...cle_todoitem_delete_ticket.cpython-311.pyc | Bin 0 -> 1818 bytes ..._description_todoitem_tags.cpython-311.pyc | Bin 0 -> 1011 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 168 -> 168 bytes core/models.py | 35 +- core/templates/base.html | 41 ++ core/templates/core/article_detail.html | 14 + core/templates/core/index.html | 226 ++++------ core/templates/core/kanban.html | 41 ++ core/templatetags/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 170 bytes .../__pycache__/core_tags.cpython-311.pyc | Bin 0 -> 525 bytes core/templatetags/core_tags.py | 7 + core/urls.py | 6 +- core/views.py | 67 +-- static/css/custom.css | 155 +++++++ staticfiles/css/custom.css | 166 ++++++- 36 files changed, 1047 insertions(+), 227 deletions(-) create mode 100644 .perm_test_apache create mode 100644 .perm_test_exec create mode 100644 ai/__init__.py create mode 100644 ai/local_ai_api.py create mode 100644 core/migrations/0002_article_todoitem_delete_ticket.py create mode 100644 core/migrations/0003_todoitem_description_todoitem_tags.py create mode 100644 core/migrations/__pycache__/0002_article_todoitem_delete_ticket.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0003_todoitem_description_todoitem_tags.cpython-311.pyc create mode 100644 core/templates/base.html create mode 100644 core/templates/core/article_detail.html create mode 100644 core/templates/core/kanban.html create mode 100644 core/templatetags/__init__.py create mode 100644 core/templatetags/__pycache__/__init__.cpython-311.pyc create mode 100644 core/templatetags/__pycache__/core_tags.cpython-311.pyc create mode 100644 core/templatetags/core_tags.py create mode 100644 static/css/custom.css diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..37a7b09 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1,3 @@ +"""Helpers for interacting with the Flatlogic AI proxy from Django code.""" + +from .local_ai_api import LocalAIApi, create_response, request, decode_json_from_response # noqa: F401 diff --git a/ai/local_ai_api.py b/ai/local_ai_api.py new file mode 100644 index 0000000..bcff732 --- /dev/null +++ b/ai/local_ai_api.py @@ -0,0 +1,420 @@ +""" +LocalAIApi — lightweight Python client for the Flatlogic AI proxy. + +Usage (inside the Django workspace): + + from ai.local_ai_api import LocalAIApi + + response = LocalAIApi.create_response({ + "input": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Summarise this text in two sentences."}, + ], + "text": {"format": {"type": "json_object"}}, + }) + + if response.get("success"): + data = LocalAIApi.decode_json_from_response(response) + # ... + +# Typical successful payload (truncated): +# { +# "id": "resp_xxx", +# "status": "completed", +# "output": [ +# {"type": "reasoning", "summary": []}, +# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]} +# ], +# "usage": { "input_tokens": 123, "output_tokens": 456 } +# } + +The helper automatically injects the project UUID header and falls back to +reading executor/.env if environment variables are missing. +""" + +from __future__ import annotations + +import json +import os +import time +import ssl +from typing import Any, Dict, Iterable, Optional +from urllib import error as urlerror +from urllib import request as urlrequest + +__all__ = [ + "LocalAIApi", + "create_response", + "request", + "fetch_status", + "await_response", + "extract_text", + "decode_json_from_response", +] + + +_CONFIG_CACHE: Optional[Dict[str, Any]] = None + + +class LocalAIApi: + """Static helpers mirroring the PHP implementation.""" + + @staticmethod + def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return create_response(params, options or {}) + + @staticmethod + def request(path: Optional[str] = None, payload: Optional[Dict[str, Any]] = None, + options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return request(path, payload or {}, options or {}) + + @staticmethod + def extract_text(response: Dict[str, Any]) -> str: + return extract_text(response) + + @staticmethod + def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return decode_json_from_response(response) + + +def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Signature compatible with the OpenAI Responses API.""" + options = options or {} + payload = dict(params) + + if not isinstance(payload.get("input"), list) or not payload["input"]: + return { + "success": False, + "error": "input_missing", + "message": 'Parameter "input" is required and must be a non-empty list.', + } + + cfg = _config() + if not payload.get("model"): + payload["model"] = cfg["default_model"] + + initial = request(options.get("path"), payload, options) + if not initial.get("success"): + return initial + + data = initial.get("data") + if isinstance(data, dict) and "ai_request_id" in data: + ai_request_id = data["ai_request_id"] + poll_timeout = int(options.get("poll_timeout", 300)) + poll_interval = int(options.get("poll_interval", 5)) + return await_response(ai_request_id, { + "interval": poll_interval, + "timeout": poll_timeout, + "headers": options.get("headers"), + "timeout_per_call": options.get("timeout"), + }) + + return initial + + +def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Perform a raw request to the AI proxy.""" + cfg = _config() + options = options or {} + + resolved_path = path or options.get("path") or cfg["responses_path"] + if not resolved_path: + return { + "success": False, + "error": "project_id_missing", + "message": "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.", + } + + project_uuid = cfg["project_uuid"] + if not project_uuid: + return { + "success": False, + "error": "project_uuid_missing", + "message": "PROJECT_UUID is not defined; aborting AI request.", + } + + if "project_uuid" not in payload and project_uuid: + payload["project_uuid"] = project_uuid + + url = _build_url(resolved_path, cfg["base_url"]) + opt_timeout = options.get("timeout") + timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout) + verify_tls = options.get("verify_tls", cfg["verify_tls"]) + + headers: Dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + cfg["project_header"]: project_uuid, + } + extra_headers = options.get("headers") + if isinstance(extra_headers, Iterable): + for header in extra_headers: + if isinstance(header, str) and ":" in header: + name, value = header.split(":", 1) + headers[name.strip()] = value.strip() + + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + return _http_request(url, "POST", body, headers, timeout, verify_tls) + + +def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Fetch status for a queued AI request.""" + cfg = _config() + options = options or {} + + project_uuid = cfg["project_uuid"] + if not project_uuid: + return { + "success": False, + "error": "project_uuid_missing", + "message": "PROJECT_UUID is not defined; aborting status check.", + } + + status_path = _resolve_status_path(ai_request_id, cfg) + url = _build_url(status_path, cfg["base_url"]) + + opt_timeout = options.get("timeout") + timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout) + verify_tls = options.get("verify_tls", cfg["verify_tls"]) + + headers: Dict[str, str] = { + "Accept": "application/json", + cfg["project_header"]: project_uuid, + } + extra_headers = options.get("headers") + if isinstance(extra_headers, Iterable): + for header in extra_headers: + if isinstance(header, str) and ":" in header: + name, value = header.split(":", 1) + headers[name.strip()] = value.strip() + + return _http_request(url, "GET", None, headers, timeout, verify_tls) + + +def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Poll status endpoint until the request is complete or timed out.""" + options = options or {} + timeout = int(options.get("timeout", 300)) + interval = int(options.get("interval", 5)) + if interval <= 0: + interval = 5 + per_call_timeout = options.get("timeout_per_call") + + deadline = time.time() + max(timeout, interval) + + while True: + status_resp = fetch_status(ai_request_id, { + "headers": options.get("headers"), + "timeout": per_call_timeout, + "verify_tls": options.get("verify_tls"), + }) + if status_resp.get("success"): + data = status_resp.get("data") or {} + if isinstance(data, dict): + status_value = data.get("status") + if status_value == "success": + return { + "success": True, + "status": 200, + "data": data.get("response", data), + } + if status_value == "failed": + return { + "success": False, + "status": 500, + "error": str(data.get("error") or "AI request failed"), + "data": data, + } + else: + return status_resp + + if time.time() >= deadline: + return { + "success": False, + "error": "timeout", + "message": "Timed out waiting for AI response.", + } + time.sleep(interval) + + +def extract_text(response: Dict[str, Any]) -> str: + """Public helper to extract plain text from a Responses payload.""" + return _extract_text(response) + + +def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Attempt to decode JSON emitted by the model (handles markdown fences).""" + text = _extract_text(response) + if text == "": + return None + + try: + decoded = json.loads(text) + if isinstance(decoded, dict): + return decoded + except json.JSONDecodeError: + pass + + stripped = text.strip() + if stripped.startswith("```json"): + stripped = stripped[7:] + if stripped.endswith("```"): + stripped = stripped[:-3] + stripped = stripped.strip() + if stripped and stripped != text: + try: + decoded = json.loads(stripped) + if isinstance(decoded, dict): + return decoded + except json.JSONDecodeError: + return None + return None + + +def _extract_text(response: Dict[str, Any]) -> str: + payload = response.get("data") if response.get("success") else response.get("response") + if isinstance(payload, dict): + output = payload.get("output") + if isinstance(output, list): + combined = "" + for item in output: + content = item.get("content") if isinstance(item, dict) else None + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "output_text" and block.get("text"): + combined += str(block["text"]) + if combined: + return combined + choices = payload.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message") + if isinstance(message, dict) and message.get("content"): + return str(message["content"]) + if isinstance(payload, str): + return payload + return "" + + +def _config() -> Dict[str, Any]: + global _CONFIG_CACHE # noqa: PLW0603 + if _CONFIG_CACHE is not None: + return _CONFIG_CACHE + + _ensure_env_loaded() + + base_url = os.getenv("AI_PROXY_BASE_URL", "https://flatlogic.com") + project_id = os.getenv("PROJECT_ID") or None + responses_path = os.getenv("AI_RESPONSES_PATH") + if not responses_path and project_id: + responses_path = f"/projects/{project_id}/ai-request" + + _CONFIG_CACHE = { + "base_url": base_url, + "responses_path": responses_path, + "project_id": project_id, + "project_uuid": os.getenv("PROJECT_UUID"), + "project_header": os.getenv("AI_PROJECT_HEADER", "project-uuid"), + "default_model": os.getenv("AI_DEFAULT_MODEL", "gpt-5-mini"), + "timeout": int(os.getenv("AI_TIMEOUT", "30")), + "verify_tls": os.getenv("AI_VERIFY_TLS", "true").lower() not in {"0", "false", "no"}, + } + return _CONFIG_CACHE + + +def _build_url(path: str, base_url: str) -> str: + trimmed = path.strip() + if trimmed.startswith("http://") or trimmed.startswith("https://"): + return trimmed + if trimmed.startswith("/"): + return f"{base_url}{trimmed}" + return f"{base_url}/{trimmed}" + + +def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str: + base_path = (cfg.get("responses_path") or "").rstrip("/") + if not base_path: + return f"/ai-request/{ai_request_id}/status" + if not base_path.endswith("/ai-request"): + base_path = f"{base_path}/ai-request" + return f"{base_path}/{ai_request_id}/status" + + +def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str], + timeout: int, verify_tls: bool) -> Dict[str, Any]: + """ + Shared HTTP helper for GET/POST requests. + """ + req = urlrequest.Request(url, data=body, method=method.upper()) + for name, value in headers.items(): + req.add_header(name, value) + + context = None + if not verify_tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + try: + with urlrequest.urlopen(req, timeout=timeout, context=context) as resp: + status = resp.getcode() + response_body = resp.read().decode("utf-8", errors="replace") + except urlerror.HTTPError as exc: + status = exc.getcode() + response_body = exc.read().decode("utf-8", errors="replace") + except Exception as exc: # pylint: disable=broad-except + return { + "success": False, + "error": "request_failed", + "message": str(exc), + } + + decoded = None + if response_body: + try: + decoded = json.loads(response_body) + except json.JSONDecodeError: + decoded = None + + if 200 <= status < 300: + return { + "success": True, + "status": status, + "data": decoded if decoded is not None else response_body, + } + + error_message = "AI proxy request failed" + if isinstance(decoded, dict): + error_message = decoded.get("error") or decoded.get("message") or error_message + elif response_body: + error_message = response_body + + return { + "success": False, + "status": status, + "error": error_message, + "response": decoded if decoded is not None else response_body, + } + + +def _ensure_env_loaded() -> None: + """Populate os.environ from executor/.env if variables are missing.""" + if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"): + return + + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) + if not os.path.exists(env_path): + return + + try: + with open(env_path, "r", encoding="utf-8") as handle: + for line in handle: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + value = value.strip().strip('\'"') + if key and not os.getenv(key): + os.environ[key] = value + except OSError: + pass diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 3d6501c67fa5c80fdda8a8ee57862699aabd319d..2267fe85726d0352dfacb391d02dbd9219870fe8 100644 GIT binary patch delta 20 acmbQwIG>ScIWI340}%YMkjtFNGZ_Fd;spNy delta 20 acmbQwIG>ScIWI340}vQ{e#)50GZ_Fd1qB`e diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index dadfaa7db630a06a9bc0da9edbc375273aefa0ed..fd340978d626b061668c11767fdecc41064fad8f 100644 GIT binary patch delta 21 bcmeyQ@JWGZIWI340}%YMkjvc2lO_NFN2vwo delta 21 bcmeyQ@JWGZIWI340}wn|@;zfCPnrM#PVNU1 diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 139db1071801d00bd7e1a167305bffd074d952f6..4067957d19699161fb330db26f242abb381b9ee9 100644 GIT binary patch delta 21 bcmey)@tuQbIWI340}%YMkjvc2lg$DEMBfFu delta 21 bcmey)@tuQbIWI340}vQ{e#+R$lg$DEL?H#( diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index 79ce690f602e05f6bfa8a8e253edcb377296b788..d3b998677fcf11f360efcbcaf6d716170305484a 100644 GIT binary patch delta 21 bcmZ3^x}23~IWI340}%YMkjvc2Gn)wjI7XW+EQ$nuC2HTX0VwB5uDV;MW<|O?om^7xpKLnuHxk6=IW$~eSfwD*8?4*P7e{14d%t#3uxp z#b|7C>Qip}HUT6fr;^>(Dz-e4-V=Z)eZJ~n8vD0~*+<|yGh^YB(g_%kl9};zt+Ku= zRTRcpHjV`o2MYP2TQA#e6eL5@4@IJ6G?3;~WsM7laVkw_Y2uc48Y!)mIE+$-@)72G z3yV$fL~!)7L6)e@!*dKXC8T#IjCkj~4|H3OcSIrmbJVWPZZvx#ZbaUtyOWXYk^rq0uE65f7$_LpBXrcfB literal 777 zcmZuu&2AGh5FYO)n~*eADlJ7tAcTbI9_T&*2&r5+Kzji$d$Bfaw{g3FwY`Geg474- zt#1&7`Z7i8Au^XroO(+Y#Gxm~n{K5_9oysi#`EWyAH7}&kbb}XH+W3|{#a0xcP-2c z6pn!bBMGD=Bm|hl+{8)U&?VpiCwq2pvX3mpNgBxnYwv zFl5to=iZw(BEps``+Gdr##1`dr84b8id+gkG>eiSlw4^pDNiGjm`*Htq&cIJ_8oZ- zGfCSz-<54EcafPc<0_V-&?3*aeb2Nh&7zc3YC4prIV%$@FHm|=M#=TdLL!u=OsFD> zhNfGg9g$#S(^EW>@qq3Ko-p+jW|#x6UIl|ZN~QjO9+|3 zv(wvq0&AzY_c>V{tsFi*@_v!kGqO5$T=I@g;ckBP;%w>pW%c;C;+HNskM4A0#kn&dbZi00ciQ0#kn&dbZi00hRKpE4HmFaZE9J_LOL diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index f6e5c4e3024d0f534a8b93046d15a44a0c8eeba6..f8814a7a0160794b0df04abfd500b585c19763ab 100644 GIT binary patch literal 1326 zcmZ`&J5L-j5VrSq4h+$j<$MG#S>kca|OK$>kWL3=niTAS7p z`lCASW?mX+uffJ}HLa0Y7+_=T8bh`(e zVUZL`AL#e;ZmMPetCqP^%Mz`I|5YORz_7EV12XKq=)IX?X^{#S(l$j+cvN-}$}6na z5VgRUhH$G~T3omy*n|-zb$zMNOI&&*fZpG(5LL z*8-1Frt0Mi!FaR(6(O+Vh6`A9qC@CKojrgb>jsbp3pROkfR?JILddGr7n6ks3)X2x zOl`%yrDBInJC5(xspH734C(`{Fo$x2@l~M?3F#o!0Q<*4T7p>XQ#5 zl)ymuDbfeJ&lN;zl%Lq2Ihc!iGp(WFC}lhYY65wn8hM}sL|Fuq^Ske2>T{5Msw88` zDao9Y%qhvi@g2JBwR#5kM*xP^v}Z@(>+Kfe7&Mh-*ZcVTB{_vNV? KKfh-(4*oBI4LeQ% literal 847 zcmZ`%v2N5r5S{hfzRN+lAV`E#KtsV5#6N(jP>?`L1C<-g@op}@*yqcxEl8IlMM?^! z^b|peKcTwvv@KP-i!P$LiWx5^vXsp3KF{pTzS)`e_tn)NpbVe?IrxR&-()o6{Zkfi zP&o$yf@P4ikTDRB;F**2kTY=LEeQ7%geTd^Z4O^>fSAzHjZ`y3>MOhD+=$rvVcE*wmN%Sy-v#{{nFS*~s5Z}Byei_I3F z4*}q}f+L2|9q#a%Djbu`qzLH0k)TLCB%;PjO-HA#Im6pfmo{&0G z>7-1H@$SGk-6$Hzxr`#yi=w;`RYv;#C_1X*%z6}c*m&raneq2inF;*^7F5ylWpGgB zGN|^daajfOvrMY8P{F4{9qLJ($RH_{4C*#+O^%IAH&rWmk~sBKgp>bcwzhHR%^tp+ z*7MA#p&nz1+CbPHxat8JeS)=STRkGJj#?+J(*V7U-bT1dFn1j9J)6VrcvbIT-{G|> zh@HCTi%~ot7K;8HOxfzNQ~SkH1>kfI1{#FQNR&oIDsb|)p)=X zfhGM5OQeZaQcj*>P+9O3b(=ENF$PJ3SCq2<1k^*qNL8Su8jBGY-x4Dt6>NJRP(2q%$3s>#5ciVQT~5{Kf`RC+kZe|Ec>aZQ)-+q0=OvS7WyLqhvYfgf_FGpA<+s1OK#0I83>}o%ykDNO7EsC zn|7U58fy*9ZB*DUGhhZ@d1~`*XGb?!#jrW6gy%KA^UP0bn&Wa!+shADIz2Gp9{c#6 zo1~k+cCgn@-RR@|E9I=8(lkr2Gfnd|ng+QVCd#s={nXIS(8F=@etKb3=SwwaQl1NR zmF>EL?k5e~a+&4&%A)QvwN_`LJ!5bN2CC^UFG9>K!mq=N7Z9+z0>JFfBY@`luPcx5 zFM0C#al9>0b>t~eo@(Cf<|g;oz1(CcSMqYD=KXHr_RCSPaQk$oU6}0@X1&5}^V^;% zNFVpeWe8`+Ney_;$9awa38stE+JIXBMZgMX;#S0gif_e-N+c05l2JY?Qn4i+K}VdN zcSSWdlqC_cl0)5+9%7|foTgiuAyy_}WiRGyWrtQIUt-8Z423FC);XGkzRUY5)u#5c z%j#vZ9P>pNv^`0+ON%zBnVPl3?G4TxXD_>Km3|l~;7YEUwz18qpPUZ`jHb55d~p$w zW7}~&7!@4SnyFjczGyW}^XxA$mg5|&lHWo26yPkVj1ou#7gW`zT{D>DCn;Ok8>Y+C z=oS1_)M@{}%eV}oTtUbKgbfzfAr|J|NOTBM%iKWfH3VGON;%IzKxGu+LjXUws@_$9 zT-6r7`F?rf-fGYz{3CQ@cW{jNvy?doukE-s+k%tTHyl6VxVqbL_!yGLk(6od(0?`w zpFr0|pL~KU0)BMocYupN>BXecM2|$!T3Nv0|=06(|H?&fg{50Lttqpscu7$UZ^c`OR;pGtQ{Qc8-m-;fiqrT>!Lk7Gj zoo4{OxF85!GTwgw-xCu8+y<9nfBnFGgZ8%<&XGKFFT?(mgXeG1{`LxI@S*+&UBjec literal 1409 zcmZuxy>Ht_6hDg8w@169EsumE? zlf`$Pzf%bPA&e=dC(6Y#C~pu!M6yv&(j^I{&?+LSSBS_|>Ox*Z$1*~vuoBW`qHLo^ z`dpX|iD!Bap|)#I;8pCw%enF6V#5KVV8(?lYlDaR0h3M(N! zdnF=GWs*6|UO7;1CQ2c>nW%gm6}Ls~ehlk0DFa+D#%O8kxPq!NTArdLe^$71(#lLk z@k+!TDZN5^m8j6XGS6t1`ILGMxsl?EGoXF0Yyk(WX!Y@cIc-K=_eF8Nf9v7I04!tL zj!Oy8ti&?TmThOB%ilTs0`E~``8}T5h^5%OW3@Y6Sp$Cl51bzcR_GZA*rxrq*Ezoi zE+!_>kXa1`&zc>_GO5e6gzg!>?J*VH8l;sB-*a%^*~bPUVCSZ`c{wkL6uNjS7jwYg zpqHo%G9kFPv*Wc|@(*sp;slDfloLldkt&Y>9Eq~YVdwz=^P!sC0*1p4@x&y*<-+HvJJ)U})xF652bPRUiqBdb4 z3OnO^hUdFHH(-{-Eblol-0K;Z9f$A=qtATmdX!-*ygaYb124gfgt{iP2A<{gx%!Ra zQO)Yn1S^<~f`eeg;{|^}COVsklD{Ih0D|2RO#I?vCJyKR4lKC*j8jTIGe%PuY>rFH zb>Wz>P`NfPp~}s}y{P=r$;V;2IV?A$ax+*RsSAfYk-BhFd;8?Kolw<=surnQaDN2R zKShZ-yTf`TsyBkIQSJ88CsFP8u(lZ07K3j` z)jLP^sCwt^QdnIcR+ppda`0g4Jn_Qn(y+P|RhNPXV?|2OkI^hk?k%po*-&_%bd7%7 zX_DP!GU*NRhm>^WQ_+^oF1^ovnGF3a_89=;-MJgU#n3W_bVr diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 4e4f113d309e34129cca5a1bb3778fb33b4fe2e4..bf27f4c0245ac1721dc784360fab4d2863578754 100644 GIT binary patch literal 548 zcmZ`!ze~eF6n>Xnn$+0(W08Ug1($%BErXU$ZWb4D$s*U>q0!`0E{WC-($UdPM<+$} zk4d3J$s{#5ySY-Mi!6``-I_-$Si72NFhm&0j(Quf^vZ`Yd_x%j5}?h%jKUL+(fl{b4AA4V&TXd1A#Sd_zs z$9O28sfrMqz-sn0Uckxzk7)(8>Tu_3`Rd?yCBw}TZjNztf(=<~TzBp+Gwh78GsaF? d%c|>lJlo#QaA$-&W89hQctIJO#SwM6#3w5ZkzxP< delta 207 zcmZ3&a+}F%IWI340}w2<{FRXfq#uJgFu)3Be6|5HrZc24q%h_%<| N&(y#Tf<-()%>WAdEkytT diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 9d0ddd892cc8db8e347c5f97af5002a4ce469452..95959c287aca5b59ae311ac6a6796dfe9f7a7edb 100644 GIT binary patch literal 2892 zcmb7GO-vhC5PtjPAMj$k0TU8P;!sH91~C1DL`q1aBux+wsRX@Pt*phn2@7j4eY@lb znbZfQ0HR8zDk7DL#EDXoLytM;-ivLminS7ls>&fZS2R+TQ|CQ<7jJ@8<*^>W^~}7P zH$UI-Yg1Drg7xsiQRR>ap})zCz1Z4jHvyYfBq51WQHDt}88*o>NMa>U2lP!E88sHPycyY;)Fez|ib*?I zenn5+5miO9cwM|BW0GY|f^Ye>i76;k-Qq-5wfq`}>P=1D$6mtZlq^^zwlMKK=COQO z{(crL;~+Vqi4YPK%a_)&hJ4S^q32MPthc@KNi8GCXD4Q}#%x@^C#Po(4ae_lcv_zk z({h|Ten*k->U}f!F)1lZh-Li`AbC{w1s5JXX)XDV6@15bLhVnkttE<~m>G&K^5sD2 z(fQ@`OBYryR1n+HT^7QRhL?wzMpi}^M|PS)w%FWdHg^?-u8Ifvul*?8vGic&!6N@M z+-8QO+u^QKxT_dGVTMm^hvTJi{P(6}c*qP7<%f4dtt&rlhdN84&SI#`40YwNSG*{4 zq#QX~4!7(?+Mc$o9r~qxt-ToOHY44Y22aRaK|u1?UJFP#@YBgcd(Y5f* zUAPW%^*6aCiRo?&^yx!x6&XaNwx*okU3Yrg=8F2e$@=a$ND0)|6uA>!ZvWhVNZc9+JsPrQ5n!9d=h*kU!!v3%?)8H!cmxQV z;j^0r@|bzZt|A4F`R>HG5Uyo*o>^iICxi&q!_guOK|l-sC2j*`x18|0o&1?yISCEQ zxU3RcYV|O+>IUVd8YmO3QGj+Ybf+<^!$@EZOQ^n(7KG*@=S1_EobuLxP8Bl~k~lOM zn-;SZVm39QiCF48r)p_Y)rb1dNlMyCYnd6?)pH1`X!K&up~gLfbJ72EsRLAX zGECoqD;2~YgeI`bU&5)y#FM@XhZ-V}rKbah&MVIILN2x^%=SdF^@iDcqr%xbRJ(c) z&I(cY0E<#p=bcNWCp9H4>zI_#T$rLr)zK>c`6;krk||6k0geE%f@9;C#=jX$UA}&E z`0~{;+)6kE=BvoY<2JJYgb)Jw_!N*@XrdQst}LNG9Jm(*NDB2Ifxz{5gceii*cPHC zAzBnVO`&sJI9(D>ZzhVuSyMQhzgl5k_oSD7+4F_jbGhOH&Oan{{}PhFSrJgj>7B!! z=HXA@2OA$xzy=6_O=AsgdN=PD1A}H@u)q(}0H&SF2KOAWnS_@QgPI)k>>VJ1=5@w4 zZC?1dDaR_zdkxH!K4532=h|qFYSSzw$%d$?G*_0d_G65v1BWKf;%>`EH7gv=iq&YV zDiZD|At%5IBc_^`pv|owj6Vk^&E99QfxHAGE82GOcqw>%Jy{4IF9!R~V1NGFPT(Vg z(qBGrcrjQQ94Q78W*|}E6V>t)tgtjCW^ZeK`lN;p7+pGbiB3)!u$m;Rdevt-Mm=OK z>(z-%tBQ^@QX|x#->Thd_dKZ2s`3y%1AKy0`U;Q=$1qG8^%nMf%BZ`r-&01%3ht+j h0(tsZ;SVr_737dp0&}K<9MZuuHyD_l`%)T&{{WXiX1V|X literal 2006 zcmZ`(&2Q936d!wcz21)`&W4tJ#pJA?V%j1zOmzNm$ozG=l3ywe)Hb% z%?~3Zc?9FF3q=E*-j z479_0?hBiOCEO9YnTVF?30CU9NR#Y?c5QI}H@-|+=}nR529D9No${FX$C{LI!+SPU zn*tq6Z168R;jN4%Z9`N$QDj{EKl`$8?z7~_^8GZONIW6PmX+H~57!dvm~Wk5i3+CD z8de>{vutlw4NQWur#5X`Qyt%cda7VivA1p$-*d4?RSQ$Yc7kfFXw>UXyVcsLn!d~A zi}m`KTw=LPjzJxN)iztXW!zM6(hUc*i5n}|uPiRy(m!3ixv;WyYhaWLKH(DYNaRP^~=u`nh_pqn_KE-I{$i*}l4) zFFl<5w)(KzUg%5c(6R8~%x?K)w>;Y`&+e9Idgb@K67l%h2GSK{&;Ro>LVcS%fBB$#nRS^&g9(F)o$TzuW+`L zKD!4&B9oJ3Bu%z=8Z?g(M*ND?L<|=k&S;V)Xju_q*%G&*sdomAt>vr~Ksn8n_|o|M zZfPBi(nO=1MF&vBofDNaO4Twisve%7U0!$``60Z5tPnIz69)lD(vOH71syrU$$JUmQZNUkJ(yv!)Lsn7 zC)-!T1E<>8!qVZbx$oAyrQ^NQ@%B|dV@KRaWr9p`SMLI0QrruKXD>#g5^JhK%_kIq z6GQ-Hg?%+d6~uuS6Agt{j`4hGUll_OS`HK2tYXWiB+LJn;b#xxJV;Saf+#^zM&kpX z;(-hf{aXGw&g*&d0T_7If)GeQB?v-@a_wk_XsjL0m*{k7c!lU_XLyC^SZ8>J=tyUH tg~Lw3=Swuz8D1eOcZOGAQiKotXh4pqg%f@BZ?e_;m1}?d4^wpVzX0UP4AcMs diff --git a/core/admin.py b/core/admin.py index 639ff3a..19afb57 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,8 +1,5 @@ from django.contrib import admin -from .models import Ticket +from .models import Article, TodoItem -@admin.register(Ticket) -class TicketAdmin(admin.ModelAdmin): - list_display = ('subject', 'status', 'priority', 'requester_email', 'created_at') - list_filter = ('status', 'priority') - search_fields = ('subject', 'requester_email', 'description') +admin.site.register(Article) +admin.site.register(TodoItem) \ No newline at end of file diff --git a/core/forms.py b/core/forms.py index 7a6b83b..01d237e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,7 +1,25 @@ from django import forms -from .models import Ticket +from .models import TodoItem -class TicketForm(forms.ModelForm): +class TodoItemForm(forms.ModelForm): class Meta: - model = Ticket - fields = ['subject', 'requester_email', 'priority', 'description'] + model = TodoItem + fields = ['title', 'description', 'tags', 'status'] + widgets = { + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter a new task...' + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'placeholder': 'Add a description...', + 'rows': 3 + }), + 'tags': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'e.g. urgent, project-x' + }), + 'status': forms.Select(attrs={ + 'class': 'form-control' + }) + } \ No newline at end of file diff --git a/core/migrations/0002_article_todoitem_delete_ticket.py b/core/migrations/0002_article_todoitem_delete_ticket.py new file mode 100644 index 0000000..3151336 --- /dev/null +++ b/core/migrations/0002_article_todoitem_delete_ticket.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.7 on 2025-11-19 21:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('content', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='TodoItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('status', models.CharField(choices=[('todo', 'To Do'), ('inprogress', 'In Progress'), ('blocked', 'Blocked'), ('done', 'Done')], default='todo', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.DeleteModel( + name='Ticket', + ), + ] diff --git a/core/migrations/0003_todoitem_description_todoitem_tags.py b/core/migrations/0003_todoitem_description_todoitem_tags.py new file mode 100644 index 0000000..db16dda --- /dev/null +++ b/core/migrations/0003_todoitem_description_todoitem_tags.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-11-19 21:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_article_todoitem_delete_ticket'), + ] + + operations = [ + migrations.AddField( + model_name='todoitem', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='todoitem', + name='tags', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc index 64d8a5533a01aeb9823459fa0e2a5787563687ab..5052f7b704f8431ce8ab5e312432abf3d7e8c318 100644 GIT binary patch delta 21 bcmeyv^M{9LIWI340}%YMkjvc2Q@{oQMoR_K delta 21 bcmeyv^M{9LIWI340}$N&_cLQ7PXQYMPud4l diff --git a/core/migrations/__pycache__/0002_article_todoitem_delete_ticket.cpython-311.pyc b/core/migrations/__pycache__/0002_article_todoitem_delete_ticket.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7933e9c0edfb461faaeec7e6e40553d213a33abb GIT binary patch literal 1818 zcmb_cJ#5=X6h2ZEB~nrWNOE3Q{6Ki+huac!NhWn=}!JlPlQun;71bF5-@6sSZE8an23n9 zIRynrW3G`m6Vm`#f+vpUL831K2zbY9UBt=AGda~@WuW%qghP1Gd{U8*GS&2fIh+az zVx%K`R4%T@(jfDGjJ7nm(idSLyyFFX-o^aU|BN|%F|RhlxnBf4gFhbRFJ8pI!;i0C z%JJHz9OEC%F*VH?7Y5fa?rFzbX*>&UP-M!}>HR}9NQOfROD`@i-qTFm^i17iiAU5k z4T~_*#LWg1SL+ptrS+cYXa*&^M=(ousoBx#uC_&Xnc@-(`^-HEv`y%m;}Xr*JA}nO z)3eC&8-P_2pzrh@%_4T&+h7U9u{~mYOr1p3bPrs{huF?bO~;s{i^9n3Cb6)~l1&FY zs~+hv+4Xd<=T;={O64%{Jt=hM~nFwG?tXYn+1?UsCF~fIb z$0kgw1Eod4d7S59V*qf&F%9Cf1Sacx&+_OD7?|4YVqSnT++a$tUq_>;PYVaPKeNh zZ^ifM_oB{Kw%FyL(hKn8`FZaWKjE$YCi>BcvCkGn;_OTfKNJ{-64 uSM&jh{9oIB4kU~Tf&jNGRDAT=-}LLqh=pJ*KAsAf6oh_D zqaKZOSdE)at z3?fGIl%`?MVgk*w9PBXIV7V}Azi3jH1w8CZR#qWNV=eNN4WT7P6getTCTSvxA2aYt z*?1eWhzc$D+u6|{sN7L`6E+C-dn95>J74F+;8<}iVUP++h_DEOR0Who#4sW63O|zR zq(xbmC6pyW$TBV;PZ&=qeJ-!znl#N-*2^nfm`~SzK2WW+%g$f(J34_lbmqD1Y0TVW ztw{31Wjz)YdCJ}QDc{Jte!yJGkbC(TxWLuJe>u9K;?gPmob7(#I$Tf*sbrr)Y+lS# zN9LnaRmSwqm!*Zw$xl|_m^d82dpJ6EXisn5@7vSg#(q58w_68xt8cf;A + + + + + {% block title %}AI Task Manager{% endblock %} + + + + + {% load static %} + + + + + +
+ {% block content %} + {% endblock %} +
+ + + + \ No newline at end of file diff --git a/core/templates/core/article_detail.html b/core/templates/core/article_detail.html new file mode 100644 index 0000000..8820990 --- /dev/null +++ b/core/templates/core/article_detail.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}{{ article.title }}{% endblock %} + +{% block content %} +
+

{{ article.title }}

+

Published on {{ article.created_at|date:"F d, Y" }}

+
+
+ {{ article.content|safe }} +
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index f4e4991..ed0a715 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,157 +1,81 @@ - - +{% extends 'base.html' %} +{% load static %} - - - - {{ project_name }} - {% if project_description %} - - - - {% endif %} - {% if project_image_url %} - - - {% endif %} - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+

Add a New Task

+
+ {% csrf_token %} +
+ {{ form.title.label_tag }} + {{ form.title }} +
+
+ {{ form.description.label_tag }} + {{ form.description }} +
+
+ {{ form.tags.label_tag }} + {{ form.tags }} +
+
+ {{ form.status.label_tag }} + {{ form.status }} +
+ +
-

Appwizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} — - UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-
- - \ No newline at end of file +
+
+

Your To-Do List

+
+
+ + + + + + + + + + + + {% for item in todo_list %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
TaskDescriptionTagsStatusCreated
{{ item.title }}{{ item.description|default:"" }} + {% if item.tags %} + {% for tag in item.tags.split|slice:":3" %} + {{ tag }} + {% endfor %} + {% endif %} + {{ item.get_status_display }}{{ item.created_at|date:"M d, Y" }}
No tasks yet. Add one above!
+
+
+ + +{% endblock %} diff --git a/core/templates/core/kanban.html b/core/templates/core/kanban.html new file mode 100644 index 0000000..20a81de --- /dev/null +++ b/core/templates/core/kanban.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% load static %} +{% load core_tags %} + +{% block title %}AI Task Manager - Kanban Board{% endblock %} + +{% block content %} +
+

Kanban Board

+

Visualize your tasks and track progress.

+
+ +
+
+ {% for status_value, status_display in status_choices %} +
+

{{ status_display }}

+
+ {% for item in tasks_by_status|get_item:status_value %} +
+
+
{{ item.title }}
+

{{ item.description|default:""|truncatewords:15 }}

+ {% if item.tags %} +
+ {% for tag in item.tags.split|slice:":3" %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+
+ {% empty %} +
No tasks in this stage.
+ {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/__pycache__/__init__.cpython-311.pyc b/core/templatetags/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a47ce4e39801999b2bc1550f2ed20e6028f308e3 GIT binary patch literal 170 zcmZ3^%ge<81b=MhGC}lX5CH>>P{wCAAY(d13PUi1CZpd;>KI5CkTQ#S}_*4?3wiASu#kq6GMV~M}0ptI|Bs6R3*z>K( zr8?Wjl&>1p&@4ql$P1LFzt2K0T_4Tm(o8PTKkko>5!6bPv-0nNJz!p_e^Qcb#j E0-6AJe*gdg literal 0 HcmV?d00001 diff --git a/core/templatetags/core_tags.py b/core/templatetags/core_tags.py new file mode 100644 index 0000000..4b04b5c --- /dev/null +++ b/core/templatetags/core_tags.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) diff --git a/core/urls.py b/core/urls.py index 6299e3d..20837c1 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,9 @@ from django.urls import path -from .views import home +from .views import index, article_detail, kanban_board urlpatterns = [ - path("", home, name="home"), + path("", index, name="index"), + path("kanban/", kanban_board, name="kanban_board"), + path("article//", article_detail, name="article_detail"), ] diff --git a/core/views.py b/core/views.py index c1a6d45..5ee7a4b 100644 --- a/core/views.py +++ b/core/views.py @@ -1,37 +1,42 @@ -import os -import platform - -from django import get_version as django_version -from django.shortcuts import render -from django.urls import reverse_lazy -from django.utils import timezone -from django.views.generic.edit import CreateView - -from .forms import TicketForm -from .models import Ticket - - -def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() +from django.shortcuts import render, redirect +from .models import Article, TodoItem +from .forms import TodoItemForm +import time +def index(request): + if request.method == 'POST': + form = TodoItemForm(request.POST) + if form.is_valid(): + form.save() + return redirect('index') + else: + form = TodoItemForm() + + todo_list = TodoItem.objects.all().order_by('-created_at') + articles = Article.objects.all() + context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + 'articles': articles, + 'todo_list': todo_list, + 'form': form, + 'timestamp': int(time.time()), } return render(request, "core/index.html", context) +def kanban_board(request): + tasks = TodoItem.objects.all().order_by('created_at') + tasks_by_status = { + status_value: list(filter(lambda t: t.status == status_value, tasks)) + for status_value, status_display in TodoItem.STATUS_CHOICES + } -class TicketCreateView(CreateView): - model = Ticket - form_class = TicketForm - template_name = "core/ticket_create.html" - success_url = reverse_lazy("home") + context = { + 'tasks_by_status': tasks_by_status, + 'status_choices': TodoItem.STATUS_CHOICES, + 'timestamp': int(time.time()), + } + return render(request, "core/kanban.html", context) + +def article_detail(request, article_id): + article = Article.objects.get(pk=article_id) + return render(request, "core/article_detail.html", {"article": article}) \ No newline at end of file diff --git a/static/css/custom.css b/static/css/custom.css new file mode 100644 index 0000000..776daea --- /dev/null +++ b/static/css/custom.css @@ -0,0 +1,155 @@ +/* custom.css */ + +:root { + --primary-color: #1A202C; + --secondary-color: #F7FAFC; + --accent-color: #4299E1; + --font-family-headings: 'Poppins', sans-serif; + --font-family-body: 'Inter', sans-serif; +} + +body { + font-family: var(--font-family-body); + background: linear-gradient(120deg, #fdfbfb 0%, #ebedee 100%); + color: #333; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-family-headings); + color: var(--primary-color); +} + +.hero-section .display-4 { + font-weight: 600; +} + +.hero-section .lead { + color: #555; + font-size: 1.2rem; +} + +.btn-primary { + background-color: var(--accent-color); + border-color: var(--accent-color); + font-weight: 600; + padding: 0.75rem 1.5rem; + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; +} + +.btn-primary:hover { + background-color: #3182ce; /* A slightly darker shade of accent */ + border-color: #2c73b9; +} + +.card { + border: none; + border-radius: 0.75rem; +} + +.card-header { + border-bottom: 1px solid #e2e8f0; +} + +.form-control { + border-radius: 0.5rem; + padding: 0.75rem 1rem; +} + +.form-control:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 0.25rem rgba(66, 153, 225, 0.25); +} + +.table { + font-size: 0.95rem; +} + +.table th { + font-weight: 600; + color: #4a5568; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom-width: 2px; +} + +.badge { + padding: 0.4em 0.7em; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; +} + +.status-todo { + background-color: #e2e8f0; + color: #4a5568; +} + +.status-inprogress { + background-color: #bee3f8; + color: #2c5282; +} + +.status-blocked { + background-color: #fed7d7; + color: #9b2c2c; +} + +.status-done { + background-color: #c6f6d5; + color: #2f855a; +} + +/* Kanban Board Styles */ +.kanban-board-container { + overflow-x: auto; + padding: 1.5rem; + background-color: #e9ecef; /* Light grey background for the container */ +} + +.kanban-board { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 280px; /* Fixed width for each column */ + gap: 1.5rem; + padding-bottom: 1rem; /* For scrollbar spacing */ +} + +.kanban-column { + flex: 1; + min-width: 280px; + max-width: 300px; + background-color: #f7fafc; + border-radius: 0.75rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; +} + +.kanban-column .h5 { + font-weight: 600; +} + +.kanban-cards { + flex-grow: 1; + overflow-y: auto; + max-height: 60vh; /* Adjust as needed */ +} + +.kanban-card { + cursor: grab; + transition: box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out; +} + +.kanban-card:hover { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transform: translateY(-3px); +} + +.kanban-card .card-title { + font-weight: 600; + color: #2d3748; +} + +.kanban-card .tags { + margin-top: 0.5rem; +} diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..776daea 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,155 @@ +/* custom.css */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --primary-color: #1A202C; + --secondary-color: #F7FAFC; + --accent-color: #4299E1; + --font-family-headings: 'Poppins', sans-serif; + --font-family-body: 'Inter', sans-serif; } + body { - margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - text-align: center; - overflow: hidden; - position: relative; + font-family: var(--font-family-body); + background: linear-gradient(120deg, #fdfbfb 0%, #ebedee 100%); + color: #333; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-family-headings); + color: var(--primary-color); +} + +.hero-section .display-4 { + font-weight: 600; +} + +.hero-section .lead { + color: #555; + font-size: 1.2rem; +} + +.btn-primary { + background-color: var(--accent-color); + border-color: var(--accent-color); + font-weight: 600; + padding: 0.75rem 1.5rem; + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; +} + +.btn-primary:hover { + background-color: #3182ce; /* A slightly darker shade of accent */ + border-color: #2c73b9; +} + +.card { + border: none; + border-radius: 0.75rem; +} + +.card-header { + border-bottom: 1px solid #e2e8f0; +} + +.form-control { + border-radius: 0.5rem; + padding: 0.75rem 1rem; +} + +.form-control:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 0.25rem rgba(66, 153, 225, 0.25); +} + +.table { + font-size: 0.95rem; +} + +.table th { + font-weight: 600; + color: #4a5568; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom-width: 2px; +} + +.badge { + padding: 0.4em 0.7em; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; +} + +.status-todo { + background-color: #e2e8f0; + color: #4a5568; +} + +.status-inprogress { + background-color: #bee3f8; + color: #2c5282; +} + +.status-blocked { + background-color: #fed7d7; + color: #9b2c2c; +} + +.status-done { + background-color: #c6f6d5; + color: #2f855a; +} + +/* Kanban Board Styles */ +.kanban-board-container { + overflow-x: auto; + padding: 1.5rem; + background-color: #e9ecef; /* Light grey background for the container */ +} + +.kanban-board { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 280px; /* Fixed width for each column */ + gap: 1.5rem; + padding-bottom: 1rem; /* For scrollbar spacing */ +} + +.kanban-column { + flex: 1; + min-width: 280px; + max-width: 300px; + background-color: #f7fafc; + border-radius: 0.75rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; +} + +.kanban-column .h5 { + font-weight: 600; +} + +.kanban-cards { + flex-grow: 1; + overflow-y: auto; + max-height: 60vh; /* Adjust as needed */ +} + +.kanban-card { + cursor: grab; + transition: box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out; +} + +.kanban-card:hover { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transform: translateY(-3px); +} + +.kanban-card .card-title { + font-weight: 600; + color: #2d3748; +} + +.kanban-card .tags { + margin-top: 0.5rem; }