T22: Prijava — dva moda (klijent forma + operater chat sa Claude API)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 13:38:05 +00:00
parent f137703f1b
commit b3645beea0
11 changed files with 1041 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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()
}

View File

@ -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); }

View File

@ -16,6 +16,7 @@
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn">Dokumenti</a>
<a href="/console" class="btn btn-active">Konzola</a>
<a href="/submit" class="btn">Prijava</a>
</nav>
</div>
</div>

View File

@ -15,6 +15,7 @@
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn btn-active">Dokumenti</a>
<a href="/console" class="btn">Konzola</a>
<a href="/submit" class="btn">Prijava</a>
</nav>
</div>
<div class="docs-container">

View File

@ -15,6 +15,7 @@
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn btn-active">Dokumenti</a>
<a href="/console" class="btn">Konzola</a>
<a href="/submit" class="btn">Prijava</a>
</nav>
</div>
<div class="docs-container">

View File

@ -26,6 +26,7 @@
<a href="/" class="btn btn-active">Kanban</a>
<a href="/docs" class="btn">Dokumenti</a>
<a href="/console" class="btn">Konzola</a>
<a href="/submit" class="btn">Prijava</a>
</nav>
</div>
</div>

View File

@ -0,0 +1,162 @@
{{define "submit"}}
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KAOS — Prijava</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<div class="header-right">
<nav class="nav">
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn">Dokumenti</a>
<a href="/console" class="btn">Konzola</a>
<a href="/submit" class="btn btn-active">Prijava</a>
</nav>
</div>
</div>
<div class="submit-container">
<div class="submit-mode-toggle">
<button class="btn btn-mode active" id="btn-client" onclick="switchMode('client')">👤 Klijent</button>
<button class="btn btn-mode" id="btn-operator" onclick="switchMode('operator')">🔧 Operater</button>
</div>
<!-- Client mode -->
<div id="mode-client" class="submit-mode">
<h2>📝 Nova prijava</h2>
<form id="client-form" onsubmit="submitClientForm(event)">
<div class="form-group">
<label>Naslov:</label>
<input type="text" name="title" id="submit-title" class="form-input" placeholder="Kratak opis problema ili ideje..." required>
</div>
<div class="form-group">
<label>Opis (opciono):</label>
<textarea name="description" id="submit-desc" class="form-input" rows="6" placeholder="Detaljniji opis..."></textarea>
</div>
<div class="form-group">
<label>Prioritet:</label>
<div class="priority-group">
<label class="priority-label"><input type="radio" name="priority" value="Nizak"> Nizak</label>
<label class="priority-label"><input type="radio" name="priority" value="Srednji" checked> Srednji</label>
<label class="priority-label"><input type="radio" name="priority" value="Visok"> Visok</label>
</div>
</div>
<button type="submit" class="btn btn-success">Pošalji 📨</button>
</form>
<div id="client-result"></div>
</div>
<!-- Operator mode -->
<div id="mode-operator" class="submit-mode" style="display:none">
<h2>🔧 Operater mod</h2>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="console-input" placeholder="Piši..." onkeydown="if(event.key==='Enter')sendChat()" autocomplete="off">
<button class="btn btn-move" onclick="sendChat()"></button>
</div>
</div>
</div>
<script>
var currentChatID = '';
function switchMode(mode) {
document.getElementById('mode-client').style.display = mode === 'client' ? 'block' : 'none';
document.getElementById('mode-operator').style.display = mode === 'operator' ? 'flex' : 'none';
document.getElementById('btn-client').classList.toggle('active', mode === 'client');
document.getElementById('btn-operator').classList.toggle('active', mode === 'operator');
}
function submitClientForm(e) {
e.preventDefault();
var form = document.getElementById('client-form');
var data = new FormData(form);
fetch('/submit/simple', {method: 'POST', body: data})
.then(function(r) { return r.json(); })
.then(function(data) {
var el = document.getElementById('client-result');
if (data.error) {
el.innerHTML = '<div class="submit-msg submit-error">' + escapeHtml(data.error) + '</div>';
} else {
el.innerHTML = '<div class="submit-msg submit-success">✅ ' + data.task_id + ' kreiran u backlog/</div>';
form.reset();
}
});
}
function sendChat() {
var input = document.getElementById('chat-input');
var msg = input.value.trim();
if (!msg) return;
var messages = document.getElementById('chat-messages');
messages.innerHTML += '<div class="chat-msg chat-user"><span class="chat-role">👤</span> ' + escapeHtml(msg) + '</div>';
input.value = '';
input.disabled = true;
var botId = 'bot-' + Date.now();
messages.innerHTML += '<div class="chat-msg chat-bot" id="' + botId + '"><span class="chat-role">🤖</span> <span class="chat-text">...</span></div>';
messages.scrollTop = messages.scrollHeight;
fetch('/submit/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: msg, chat_id: currentChatID})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
document.getElementById(botId).querySelector('.chat-text').textContent = 'Greška: ' + data.error;
input.disabled = false;
return;
}
currentChatID = data.chat_id;
streamChatResponse(data.chat_id, botId);
})
.catch(function(err) {
document.getElementById(botId).querySelector('.chat-text').textContent = 'Greška: ' + err.message;
input.disabled = false;
});
}
function streamChatResponse(chatID, botId) {
var source = new EventSource('/submit/chat/stream/' + chatID);
var el = document.getElementById(botId);
var input = document.getElementById('chat-input');
source.addEventListener('message', function(e) {
if (el) {
el.querySelector('.chat-text').textContent = e.data;
var messages = document.getElementById('chat-messages');
messages.scrollTop = messages.scrollHeight;
}
});
source.addEventListener('done', function() {
source.close();
input.disabled = false;
input.focus();
});
source.onerror = function() {
source.close();
input.disabled = false;
};
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>
{{end}}