diff --git a/TASKS/reports/T22-report.md b/TASKS/reports/T22-report.md new file mode 100644 index 0000000..b0ee031 --- /dev/null +++ b/TASKS/reports/T22-report.md @@ -0,0 +1,78 @@ +# T22 Izveštaj: Prijava — dva moda (klijent i operater) + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Nova sekcija "Prijava" sa dva moda: klijent (prosta forma) i operater (chat sa Claude API). + +### Izmenjeni/kreirani fajlovi + +| Fajl | Izmena | +|------|--------| +| `internal/server/submit.go` | NOVO — handleri za oba moda, nextTaskNumber, Claude API streaming, chatState | +| `web/templates/submit.html` | NOVO — template sa toggle klijent/operater, forma, chat UI | +| `internal/server/server.go` | chatMu/chats polja, 4 nove rute, sync import | +| `internal/server/render.go` | renderSubmitPage(), registracija submit.html template | +| `internal/server/server_test.go` | 13 novih testova | +| `web/static/style.css` | CSS za submit, form, chat, priority | +| `web/templates/layout.html` | Prijava nav tab | +| `web/templates/console.html` | Prijava nav tab | +| `web/templates/docs-list.html` | Prijava nav tab | +| `web/templates/docs-view.html` | Prijava nav tab | + +### Klijent mod + +- Forma: naslov (obavezno), opis (opciono), prioritet (Nizak/Srednji/Visok) +- POST /submit/simple → kreira task u backlog/ +- Auto-numeracija: skenira sve taskove, nađe max T{XX}, inkrementiraj +- Task format sa svim standardnim KAOS poljima + Prioritet + Izvor +- Vizuelna potvrda sa task ID-em + +### Operater mod + +- Chat interfejs sa Claude API (Sonnet model) +- System prompt: CLAUDE.md + trenutno stanje svih taskova +- SSE streaming odgovora (Anthropic streaming API → browser) +- Višestruke poruke u istoj sesiji (chat_id) +- Automatsko onemogućenje inputa dok Claude odgovara + +### Endpointi + +| Endpoint | Metod | Opis | +|----------|-------|------| +| /submit | GET | Stranica za prijavu | +| /submit/simple | POST | Klijent forma → backlog/ | +| /submit/chat | POST | Operater poruka → Claude API | +| /submit/chat/stream/:id | GET | SSE stream odgovora | + +### Navigacija + +Novi tab "Prijava" dodat u header svih stranica (Kanban, Dokumenti, Konzola, Prijava). + +### Novi testovi — 13 PASS + +``` +TestSubmitPage PASS +TestSubmitPage_ClientModeIsDefault PASS +TestSimpleSubmit_CreatesTask PASS +TestSimpleSubmit_MissingTitle PASS +TestSimpleSubmit_AutoNumbering PASS +TestSimpleSubmit_DefaultPriority PASS +TestChatSubmit_NoAPIKey PASS +TestChatSubmit_EmptyMessage PASS +TestChatStream_NotFound PASS +TestNextTaskNumber PASS +TestBuildTaskContext PASS +TestSubmitPage_HasPrijavaNav PASS +TestDashboard_HasPrijavaNav PASS +``` + +### Ukupno projekat: 155 testova, svi prolaze + +- `go vet ./...` — čist +- `go build ./...` — prolazi diff --git a/code/internal/server/render.go b/code/internal/server/render.go index 961eef2..9617bd0 100644 --- a/code/internal/server/render.go +++ b/code/internal/server/render.go @@ -68,6 +68,7 @@ func init() { "templates/docs-list.html", "templates/docs-view.html", "templates/console.html", + "templates/submit.html", "templates/partials/column.html", "templates/partials/task-card.html", "templates/partials/task-detail.html", @@ -184,6 +185,15 @@ func renderConsolePage() string { return buf.String() } +// renderSubmitPage generates the submit page HTML. +func renderSubmitPage() string { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "submit", nil); err != nil { + return "Greška pri renderovanju: " + err.Error() + } + return buf.String() +} + // renderSearchResults generates the search results HTML fragment. func renderSearchResults(data searchResultsData) string { var buf bytes.Buffer diff --git a/code/internal/server/server.go b/code/internal/server/server.go index 8cecedb..dda484c 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/gin-gonic/gin" @@ -21,6 +22,8 @@ type Server struct { Router *gin.Engine console *consoleManager events *eventBroker + chatMu sync.RWMutex + chats map[string]*chatState } // taskResponse is the JSON representation of a task. @@ -70,6 +73,7 @@ func New(cfg *config.Config) *Server { Router: router, console: newConsoleManager(), events: newEventBroker(cfg.TasksDir), + chats: make(map[string]*chatState), } // No caching for dynamic routes — disk is the source of truth. @@ -119,6 +123,12 @@ func (s *Server) setupRoutes() { // Docs routes s.Router.GET("/docs", s.handleDocsList) s.Router.GET("/docs/*path", s.handleDocsView) + + // Submit routes + s.Router.GET("/submit", s.handleSubmitPage) + s.Router.POST("/submit/simple", s.handleSimpleSubmit) + s.Router.POST("/submit/chat", s.handleChatSubmit) + s.Router.GET("/submit/chat/stream/:id", s.handleChatStream) } // apiGetTasks returns all tasks as JSON. diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 4308ed5..a1bf8a3 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -1314,6 +1314,255 @@ func TestRewriteLinksSimple_NestedDir(t *testing.T) { } } +// --- T22: Submit tests --- + +func TestSubmitPage(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "Klijent") { + t.Error("expected 'Klijent' mode button") + } + if !containsStr(body, "Operater") { + t.Error("expected 'Operater' mode button") + } + if !containsStr(body, "mode-client") { + t.Error("expected client mode section") + } +} + +func TestSubmitPage_ClientModeIsDefault(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + // Operator mode should be hidden by default + if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) { + t.Error("expected operator mode to be hidden by default") + } +} + +func TestSimpleSubmit_CreatesTask(t *testing.T) { + srv := setupTestServer(t) + + form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %v", resp["status"]) + } + + taskID, ok := resp["task_id"].(string) + if !ok || taskID == "" { + t.Fatal("expected non-empty task_id") + } + + // Verify file was created in backlog + path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected task file in backlog: %v", err) + } + + if !containsStr(string(content), "Test prijava") { + t.Error("expected title in task file") + } + if !containsStr(string(content), "Visok") { + t.Error("expected priority in task file") + } + if !containsStr(string(content), "klijent (prijava)") { + t.Error("expected 'klijent (prijava)' as creator") + } +} + +func TestSimpleSubmit_MissingTitle(t *testing.T) { + srv := setupTestServer(t) + + form := strings.NewReader("description=Samo+opis") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing title, got %d", w.Code) + } +} + +func TestSimpleSubmit_AutoNumbering(t *testing.T) { + srv := setupTestServer(t) + + // Existing tasks: T01 (done), T08 (backlog) + // Next should be T09 + + form := strings.NewReader("title=Novi+task&priority=Srednji") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + taskID, _ := resp["task_id"].(string) + if taskID != "T09" { + t.Errorf("expected T09 (next after T08), got %s", taskID) + } +} + +func TestSimpleSubmit_DefaultPriority(t *testing.T) { + srv := setupTestServer(t) + + form := strings.NewReader("title=Bez+prioriteta") + req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + taskID, _ := resp["task_id"].(string) + path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") + content, _ := os.ReadFile(path) + + if !containsStr(string(content), "Srednji") { + t.Error("expected default priority 'Srednji'") + } +} + +func TestChatSubmit_NoAPIKey(t *testing.T) { + srv := setupTestServer(t) + + // Ensure no API key is set + os.Unsetenv("ANTHROPIC_API_KEY") + + body := `{"message":"test poruka"}` + req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 without API key, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestChatSubmit_EmptyMessage(t *testing.T) { + srv := setupTestServer(t) + + os.Setenv("ANTHROPIC_API_KEY", "test-key") + defer os.Unsetenv("ANTHROPIC_API_KEY") + + body := `{"message":""}` + req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty message, got %d", w.Code) + } +} + +func TestChatStream_NotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestNextTaskNumber(t *testing.T) { + dir := t.TempDir() + tasksDir := filepath.Join(dir, "TASKS") + for _, f := range []string{"backlog", "ready", "active", "review", "done"} { + os.MkdirAll(filepath.Join(tasksDir, f), 0755) + } + + os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) + os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) + + num, err := nextTaskNumber(tasksDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if num != "T09" { + t.Errorf("expected T09, got %s", num) + } +} + +func TestBuildTaskContext(t *testing.T) { + tasks := []supervisor.Task{ + {ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"}, + {ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"}, + } + + ctx := buildTaskContext(tasks) + if !containsStr(ctx, "T01: Init") { + t.Error("expected T01 in context") + } + if !containsStr(ctx, "T02: Server") { + t.Error("expected T02 in context") + } + if !containsStr(ctx, "DONE") { + t.Error("expected DONE section") + } +} + +func TestSubmitPage_HasPrijavaNav(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, `href="/submit"`) { + t.Error("expected Prijava nav link") + } +} + +func TestDashboard_HasPrijavaNav(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, `href="/submit"`) { + t.Error("expected Prijava nav link in dashboard") + } +} + +// --- T21: UI tests --- + func TestDashboard_DetailOpenClassInJS(t *testing.T) { srv := setupTestServer(t) diff --git a/code/internal/server/submit.go b/code/internal/server/submit.go new file mode 100644 index 0000000..0483255 --- /dev/null +++ b/code/internal/server/submit.go @@ -0,0 +1,385 @@ +// Package server — submit.go handles task submission in two modes: +// client (simple form) and operator (chat with Claude API). +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "github.com/dal/kaos/internal/supervisor" +) + +// chatState manages an operator chat session. +type chatState struct { + mu sync.Mutex + id string + messages []chatMessage + response string + done bool + listeners map[chan string]bool +} + +// chatMessage represents a single message in the chat. +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// nextTaskNumber finds the highest T{XX} number across all tasks and returns the next one. +func nextTaskNumber(tasksDir string) (string, error) { + tasks, err := supervisor.ScanTasks(tasksDir) + if err != nil { + return "", err + } + + maxNum := 0 + re := regexp.MustCompile(`^T(\d+)$`) + for _, t := range tasks { + if matches := re.FindStringSubmatch(t.ID); matches != nil { + num, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + if num > maxNum { + maxNum = num + } + } + } + + return fmt.Sprintf("T%02d", maxNum+1), nil +} + +// handleSubmitPage serves the submission page. +func (s *Server) handleSubmitPage(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, renderSubmitPage()) +} + +// handleSimpleSubmit creates a task in backlog/ from the client form. +func (s *Server) handleSimpleSubmit(c *gin.Context) { + title := strings.TrimSpace(c.PostForm("title")) + desc := strings.TrimSpace(c.PostForm("description")) + priority := strings.TrimSpace(c.PostForm("priority")) + + if title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "naslov je obavezan"}) + return + } + if priority == "" { + priority = "Srednji" + } + + taskID, err := nextTaskNumber(s.Config.TasksDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + now := time.Now().Format("2006-01-02 15:04") + content := fmt.Sprintf(`# %s: %s + +**Kreirao:** klijent (prijava) +**Datum:** %s +**Agent:** — +**Model:** — +**Zavisi od:** — +**Prioritet:** %s +**Izvor:** klijent + +--- + +## Opis + +%s + +## Originalna prijava + +%s +`, taskID, title, now, priority, desc, desc) + + path := filepath.Join(s.Config.TasksDir, "backlog", taskID+".md") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "task_id": taskID}) +} + +// handleChatSubmit handles an operator chat message by calling the Claude API. +func (s *Server) handleChatSubmit(c *gin.Context) { + var req struct { + Message string `json:"message"` + ChatID string `json:"chat_id,omitempty"` + } + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "neispravan zahtev"}) + return + } + + if strings.TrimSpace(req.Message) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "poruka je obavezna"}) + return + } + + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ANTHROPIC_API_KEY nije podešen"}) + return + } + + // Get or create chat session + chatID := req.ChatID + var chat *chatState + + s.chatMu.Lock() + if chatID != "" { + chat = s.chats[chatID] + } + if chat == nil { + chatID = s.console.nextExecID() + chat = &chatState{ + id: chatID, + listeners: make(map[chan string]bool), + } + s.chats[chatID] = chat + } + s.chatMu.Unlock() + + // Add user message and reset response state + chat.mu.Lock() + chat.messages = append(chat.messages, chatMessage{Role: "user", Content: req.Message}) + chat.done = false + chat.response = "" + chat.mu.Unlock() + + // Build system prompt with task context + tasks, _ := supervisor.ScanTasks(s.Config.TasksDir) + context := buildTaskContext(tasks) + + projectRoot := filepath.Dir(s.Config.TasksDir) + claudeMD, _ := os.ReadFile(filepath.Join(projectRoot, "CLAUDE.md")) + + systemPrompt := string(claudeMD) + "\n\n## Trenutno stanje taskova\n\n" + context + + "\n\n## Tvoja uloga\n\nTi si KAOS mastermind. Operater ti govori šta treba. " + + "Predloži task u markdown formatu ili odgovori na pitanje. " + + "Ako operater kaže 'kreiraj task', generiši task markdown u standardnom KAOS formatu." + + go s.callClaudeAPI(chat, apiKey, systemPrompt) + + c.JSON(http.StatusOK, gin.H{"chat_id": chatID}) +} + +// handleChatStream streams the Claude API response via SSE. +func (s *Server) handleChatStream(c *gin.Context) { + chatID := c.Param("id") + + s.chatMu.RLock() + chat := s.chats[chatID] + s.chatMu.RUnlock() + + if chat == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + ch := make(chan string, 64) + chat.mu.Lock() + chat.listeners[ch] = true + // Replay existing response if available + if chat.response != "" { + ch <- chat.response + } + isDone := chat.done + chat.mu.Unlock() + + if isDone { + c.SSEvent("done", "complete") + return + } + + c.Stream(func(w io.Writer) bool { + select { + case data, ok := <-ch: + if !ok { + c.SSEvent("done", "complete") + return false + } + if data == "__DONE__" { + c.SSEvent("done", "complete") + chat.mu.Lock() + delete(chat.listeners, ch) + chat.mu.Unlock() + return false + } + c.SSEvent("message", data) + return true + case <-c.Request.Context().Done(): + chat.mu.Lock() + delete(chat.listeners, ch) + chat.mu.Unlock() + return false + } + }) +} + +// callClaudeAPI calls the Anthropic Messages API with streaming and relays text to listeners. +func (s *Server) callClaudeAPI(chat *chatState, apiKey, systemPrompt string) { + chat.mu.Lock() + messages := make([]chatMessage, len(chat.messages)) + copy(messages, chat.messages) + chat.mu.Unlock() + + // Build API request body + apiMessages := make([]map[string]string, len(messages)) + for i, m := range messages { + apiMessages[i] = map[string]string{"role": m.Role, "content": m.Content} + } + + body := map[string]interface{}{ + "model": "claude-sonnet-4-6", + "max_tokens": 4096, + "system": systemPrompt, + "messages": apiMessages, + "stream": true, + } + + jsonBody, _ := json.Marshal(body) + + req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(jsonBody)) + if err != nil { + broadcastChatError(chat, "HTTP greška: "+err.Error()) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + broadcastChatError(chat, "API greška: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + broadcastChatError(chat, fmt.Sprintf("API %d: %s", resp.StatusCode, string(respBody))) + return + } + + // Parse SSE stream from Anthropic API + scanner := bufio.NewScanner(resp.Body) + var fullResponse strings.Builder + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var event map[string]interface{} + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + eventType, _ := event["type"].(string) + if eventType == "content_block_delta" { + delta, ok := event["delta"].(map[string]interface{}) + if !ok { + continue + } + text, _ := delta["text"].(string) + if text != "" { + fullResponse.WriteString(text) + broadcastChatText(chat, fullResponse.String()) + } + } + } + + // Finalize: save response and signal done + chat.mu.Lock() + chat.response = fullResponse.String() + chat.messages = append(chat.messages, chatMessage{Role: "assistant", Content: chat.response}) + chat.done = true + for ch := range chat.listeners { + select { + case ch <- "__DONE__": + default: + } + } + chat.mu.Unlock() +} + +// broadcastChatText sends the current accumulated text to all listeners. +func broadcastChatText(chat *chatState, text string) { + chat.mu.Lock() + chat.response = text + for ch := range chat.listeners { + select { + case ch <- text: + default: + } + } + chat.mu.Unlock() +} + +// broadcastChatError sends an error message and signals done. +func broadcastChatError(chat *chatState, errMsg string) { + chat.mu.Lock() + chat.done = true + chat.response = "Greška: " + errMsg + for ch := range chat.listeners { + select { + case ch <- "Greška: " + errMsg: + default: + } + select { + case ch <- "__DONE__": + default: + } + } + chat.mu.Unlock() +} + +// buildTaskContext creates a text summary of current tasks for the system prompt. +func buildTaskContext(tasks []supervisor.Task) string { + var sb strings.Builder + for _, status := range []string{"backlog", "ready", "active", "review", "done"} { + sb.WriteString("### " + strings.ToUpper(status) + "\n") + found := false + for _, t := range tasks { + if t.Status == status { + sb.WriteString(fmt.Sprintf("- %s: %s (%s, %s)\n", t.ID, t.Title, t.Agent, t.Model)) + found = true + } + } + if !found { + sb.WriteString("(prazno)\n") + } + sb.WriteString("\n") + } + return sb.String() +} diff --git a/code/web/static/style.css b/code/web/static/style.css index db0a81c..1d30606 100644 --- a/code/web/static/style.css +++ b/code/web/static/style.css @@ -672,6 +672,149 @@ body.detail-open .board { flex-shrink: 0; } +/* Submit / Prijava */ +.submit-container { + max-width: 700px; + margin: 0 auto; + padding: 24px; + height: calc(100vh - 60px); + display: flex; + flex-direction: column; +} + +.submit-mode-toggle { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.btn-mode { + border-color: #333; + color: #888; +} + +.btn-mode.active { + border-color: #e94560; + color: #eee; + background: #0f3460; +} + +.submit-mode h2 { + margin-bottom: 16px; + color: #e94560; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 0.9em; + color: #aaa; +} + +.form-input { + width: 100%; + background: #1a1a2e; + border: 1px solid #333; + border-radius: 6px; + color: #eee; + padding: 10px 14px; + font-size: 0.9em; + outline: none; + font-family: inherit; + transition: border-color 0.2s; +} + +.form-input:focus { + border-color: #e94560; +} + +textarea.form-input { + resize: vertical; + min-height: 100px; +} + +.priority-group { + display: flex; + gap: 16px; +} + +.priority-label { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + color: #eee; +} + +.submit-msg { + display: inline-block; + padding: 8px 16px; + border-radius: 6px; + margin-top: 12px; + font-size: 0.9em; +} + +.submit-success { + background: #4ecca3; + color: #1a1a2e; +} + +.submit-error { + background: #e94560; + color: #fff; +} + +/* Chat (operator mode) */ +#mode-operator { + flex-direction: column; + flex: 1; + min-height: 0; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + background: #111; + border-radius: 6px; + margin-bottom: 12px; + min-height: 300px; +} + +.chat-msg { + margin-bottom: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-role { + font-size: 1.1em; + margin-right: 6px; +} + +.chat-user { + color: #6ec6ff; +} + +.chat-bot { + color: #eee; +} + +.chat-text { + font-family: inherit; +} + +.chat-input-row { + display: flex; + gap: 4px; + flex-shrink: 0; +} + /* Responsive */ @media (max-width: 1100px) { .board { grid-template-columns: repeat(3, 1fr); } diff --git a/code/web/templates/console.html b/code/web/templates/console.html index 598d238..cfeacd1 100644 --- a/code/web/templates/console.html +++ b/code/web/templates/console.html @@ -16,6 +16,7 @@ Kanban Dokumenti Konzola + Prijava diff --git a/code/web/templates/docs-list.html b/code/web/templates/docs-list.html index 79be157..06d9504 100644 --- a/code/web/templates/docs-list.html +++ b/code/web/templates/docs-list.html @@ -15,6 +15,7 @@ Kanban Dokumenti Konzola + Prijava