Code Warning Fixes

This commit is contained in:
2026-02-28 06:11:07 +00:00
parent 2e94a84054
commit 3e33a1317a
28 changed files with 1317 additions and 452 deletions

View File

@@ -124,6 +124,10 @@ func main() {
auth.Post("/refresh", handlers.RefreshToken)
auth.Post("/logout", middleware.Protected(), handlers.Logout)
// Public avatar/media routes (no auth needed for <img> tags)
api.Get("/avatar/:userId", handlers.ServePublicAvatar)
api.Get("/team-media/:teamId/:imageType", handlers.ServePublicTeamImage)
users := api.Group("/users", middleware.Protected())
users.Get("/me", handlers.GetMe)
users.Put("/me", handlers.UpdateMe)

BIN
server/fpmb_server Executable file

Binary file not shown.

View File

@@ -184,28 +184,30 @@ func TeamChatWS(c *websocket.Conn) {
}
var incoming struct {
Type string `json:"type"`
Content string `json:"content"`
Type string `json:"type"`
Content string `json:"content"`
MessageID string `json:"message_id,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
}
if json.Unmarshal(msg, &incoming) != nil {
continue
}
teamOID, err := primitive.ObjectIDFromHex(teamID)
if err != nil {
continue
}
userOID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
continue
}
if incoming.Type == "message" {
content := strings.TrimSpace(incoming.Content)
if content == "" || len(content) > 5000 {
continue
}
teamOID, err := primitive.ObjectIDFromHex(teamID)
if err != nil {
continue
}
userOID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
continue
}
chatMsg := models.ChatMessage{
ID: primitive.NewObjectID(),
TeamID: teamOID,
@@ -215,6 +217,12 @@ func TeamChatWS(c *websocket.Conn) {
CreatedAt: time.Now(),
}
if incoming.ReplyTo != "" {
if replyID, err := primitive.ObjectIDFromHex(incoming.ReplyTo); err == nil {
chatMsg.ReplyTo = &replyID
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg)
cancel()
@@ -224,6 +232,56 @@ func TeamChatWS(c *websocket.Conn) {
"message": chatMsg,
})
room.broadcastAll(outMsg)
} else if incoming.Type == "edit" {
content := strings.TrimSpace(incoming.Content)
if content == "" || len(content) > 5000 || incoming.MessageID == "" {
continue
}
msgID, err := primitive.ObjectIDFromHex(incoming.MessageID)
if err != nil {
continue
}
now := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
res, err := database.GetCollection("chat_messages").UpdateOne(ctx,
bson.M{"_id": msgID, "user_id": userOID, "team_id": teamOID, "deleted": bson.M{"$ne": true}},
bson.M{"$set": bson.M{"content": content, "edited_at": now}},
)
cancel()
if err == nil && res.ModifiedCount > 0 {
outMsg, _ := json.Marshal(map[string]interface{}{
"type": "edit",
"message_id": msgID.Hex(),
"content": content,
"edited_at": now,
})
room.broadcastAll(outMsg)
}
} else if incoming.Type == "delete" {
if incoming.MessageID == "" {
continue
}
msgID, err := primitive.ObjectIDFromHex(incoming.MessageID)
if err != nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
res, err := database.GetCollection("chat_messages").UpdateOne(ctx,
bson.M{"_id": msgID, "user_id": userOID, "team_id": teamOID},
bson.M{"$set": bson.M{"deleted": true, "content": ""}},
)
cancel()
if err == nil && res.ModifiedCount > 0 {
outMsg, _ := json.Marshal(map[string]interface{}{
"type": "delete",
"message_id": msgID.Hex(),
})
room.broadcastAll(outMsg)
}
}
if incoming.Type == "typing" {

View File

@@ -526,12 +526,12 @@ func uploadTeamImage(c *fiber.Ctx, imageType string) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"})
}
imageURL := fmt.Sprintf("/api/teams/%s/%s", teamID.Hex(), imageType)
imageURL := fmt.Sprintf("/api/team-media/%s/%s", teamID.Hex(), imageType)
field := imageType + "_url"
col := database.GetCollection("teams")
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{
field: imageURL,
field: imageURL,
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"})
@@ -600,3 +600,25 @@ func ServeTeamAvatar(c *fiber.Ctx) error {
func ServeTeamBanner(c *fiber.Ctx) error {
return serveTeamImage(c, "banner")
}
func ServePublicTeamImage(c *fiber.Ctx) error {
teamID := c.Params("teamId")
imageType := c.Params("imageType")
if _, err := primitive.ObjectIDFromHex(teamID); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
if imageType != "avatar" && imageType != "banner" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"})
}
dir := filepath.Join("../data/teams", teamID)
for ext := range allowedImageExts {
p := filepath.Join(dir, imageType+ext)
if _, err := os.Stat(p); err == nil {
c.Set("Cache-Control", "public, max-age=3600")
return c.SendFile(p)
}
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"})
}

View File

@@ -153,7 +153,7 @@ func UploadUserAvatar(c *fiber.Ctx) error {
col := database.GetCollection("users")
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{
"avatar_url": "/api/users/me/avatar",
"avatar_url": "/api/avatar/" + userID.Hex(),
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"})
@@ -184,6 +184,24 @@ func ServeUserAvatar(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"})
}
func ServePublicAvatar(c *fiber.Ctx) error {
userID := c.Params("userId")
if _, err := primitive.ObjectIDFromHex(userID); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
dir := filepath.Join("../data/users", userID)
for ext := range allowedImageExts {
p := filepath.Join(dir, "avatar"+ext)
if _, err := os.Stat(p); err == nil {
c.Set("Cache-Control", "public, max-age=3600")
return c.SendFile(p)
}
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"})
}
func ChangePassword(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {

View File

@@ -73,20 +73,22 @@ type Subtask struct {
}
type Card struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Title string `bson:"title" json:"title"`
Description string `bson:"description" json:"description"`
Priority string `bson:"priority" json:"priority"`
Color string `bson:"color" json:"color"`
DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
Assignees []string `bson:"assignees" json:"assignees"`
Subtasks []Subtask `bson:"subtasks" json:"subtasks"`
Position int `bson:"position" json:"position"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Title string `bson:"title" json:"title"`
Description string `bson:"description" json:"description"`
Priority string `bson:"priority" json:"priority"`
Color string `bson:"color" json:"color"`
DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
Assignees []string `bson:"assignees" json:"assignees"`
EstimatedMinutes *int `bson:"estimated_minutes,omitempty" json:"estimated_minutes,omitempty"`
ActualMinutes *int `bson:"actual_minutes,omitempty" json:"actual_minutes,omitempty"`
Subtasks []Subtask `bson:"subtasks" json:"subtasks"`
Position int `bson:"position" json:"position"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Event struct {
@@ -175,10 +177,13 @@ type APIKey struct {
}
type ChatMessage struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
UserName string `bson:"user_name" json:"user_name"`
Content string `bson:"content" json:"content"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
UserName string `bson:"user_name" json:"user_name"`
Content string `bson:"content" json:"content"`
ReplyTo *primitive.ObjectID `bson:"reply_to,omitempty" json:"reply_to,omitempty"`
EditedAt *time.Time `bson:"edited_at,omitempty" json:"edited_at,omitempty"`
Deleted bool `bson:"deleted,omitempty" json:"deleted,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}

View File

@@ -276,7 +276,7 @@ export const board = {
createCard: (
projectId: string,
columnId: string,
data: Pick<Card, 'title' | 'description' | 'priority' | 'color' | 'due_date' | 'assignees'>
data: Pick<Card, 'title' | 'description' | 'priority' | 'color' | 'due_date' | 'assignees' | 'estimated_minutes' | 'actual_minutes'>
) =>
apiFetch<Card>(`/projects/${projectId}/columns/${columnId}/cards`, {
method: 'POST',
@@ -287,7 +287,7 @@ export const board = {
export const cards = {
update: (
cardId: string,
data: Partial<Pick<Card, 'title' | 'description' | 'priority' | 'color' | 'due_date' | 'assignees' | 'subtasks'>>
data: Partial<Pick<Card, 'title' | 'description' | 'priority' | 'color' | 'due_date' | 'assignees' | 'subtasks' | 'estimated_minutes' | 'actual_minutes'>>
) => apiFetch<Card>(`/cards/${cardId}`, { method: 'PUT', body: JSON.stringify(data) }),
move: (cardId: string, column_id: string, position: number) =>

View File

@@ -1,70 +1,131 @@
<script lang="ts">
import Markdown from '$lib/components/Markdown/Markdown.svelte';
import type { FileItem } from '$lib/types/api';
import { getAccessToken } from '$lib/api/client';
import { files as filesApi } from '$lib/api';
import Markdown from "$lib/components/Markdown/Markdown.svelte";
import type { FileItem } from "$lib/types/api";
import { getAccessToken } from "$lib/api/client";
import { files as filesApi } from "$lib/api";
let { file = $bindable<FileItem | null>(null), downloadUrl }: {
let {
file = $bindable<FileItem | null>(null),
downloadUrl,
}: {
file: FileItem | null;
downloadUrl: (id: string) => string;
} = $props();
type ViewerType = 'pdf' | 'image' | 'video' | 'audio' | 'markdown' | 'text' | 'none';
type ViewerType =
| "pdf"
| "image"
| "video"
| "audio"
| "markdown"
| "text"
| "none";
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const VIDEO_EXTS = new Set(['mp4', 'webm', 'ogv', 'mov']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']);
const IMAGE_EXTS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"ico",
]);
const VIDEO_EXTS = new Set(["mp4", "webm", "ogv", "mov"]);
const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "flac", "m4a", "aac"]);
const TEXT_EXTS = new Set([
'txt', 'json', 'csv', 'xml', 'yaml', 'yml', 'toml', 'ini', 'env',
'sh', 'bash', 'zsh', 'fish',
'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs',
'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php',
'html', 'css', 'scss', 'less', 'svelte', 'vue',
'sql', 'graphql', 'proto', 'dockerfile', 'makefile',
'log', 'gitignore', 'gitattributes', 'editorconfig',
"txt",
"json",
"csv",
"xml",
"yaml",
"yml",
"toml",
"ini",
"env",
"sh",
"bash",
"zsh",
"fish",
"js",
"ts",
"jsx",
"tsx",
"mjs",
"cjs",
"py",
"go",
"rs",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"rb",
"php",
"html",
"css",
"scss",
"less",
"svelte",
"vue",
"sql",
"graphql",
"proto",
"dockerfile",
"makefile",
"log",
"gitignore",
"gitattributes",
"editorconfig",
]);
function ext(filename: string): string {
const parts = filename.toLowerCase().split('.');
return parts.length > 1 ? parts[parts.length - 1] : '';
const parts = filename.toLowerCase().split(".");
return parts.length > 1 ? parts[parts.length - 1] : "";
}
function viewerType(f: FileItem): ViewerType {
const e = ext(f.name);
if (e === 'pdf') return 'pdf';
if (IMAGE_EXTS.has(e)) return 'image';
if (VIDEO_EXTS.has(e)) return 'video';
if (AUDIO_EXTS.has(e)) return 'audio';
if (e === 'md' || e === 'mdx') return 'markdown';
if (TEXT_EXTS.has(e)) return 'text';
return 'none';
if (e === "pdf") return "pdf";
if (IMAGE_EXTS.has(e)) return "image";
if (VIDEO_EXTS.has(e)) return "video";
if (AUDIO_EXTS.has(e)) return "audio";
if (e === "md" || e === "mdx") return "markdown";
if (TEXT_EXTS.has(e)) return "text";
return "none";
}
function authFetch(url: string): Promise<Response> {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(url, { headers });
}
let textContent = $state('');
let textContent = $state("");
let textLoading = $state(false);
let textError = $state('');
let textError = $state("");
let blobUrl = $state('');
let blobUrl = $state("");
let blobLoading = $state(false);
let blobError = $state('');
let blobError = $state("");
let activeType = $derived(file ? viewerType(file) : 'none');
let rawUrl = $derived(file ? downloadUrl(file.id) : '');
let activeType = $derived(file ? viewerType(file) : "none");
let rawUrl = $derived(file ? downloadUrl(file.id) : "");
$effect(() => {
const needsBlob = activeType === 'pdf' || activeType === 'image' || activeType === 'video' || activeType === 'audio';
const needsBlob =
activeType === "pdf" ||
activeType === "image" ||
activeType === "video" ||
activeType === "audio";
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = '';
blobUrl = "";
}
blobError = '';
blobError = "";
if (!file || !needsBlob) return;
blobLoading = true;
authFetch(rawUrl)
@@ -72,9 +133,15 @@
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then((b) => { blobUrl = URL.createObjectURL(b); })
.catch((e) => { blobError = e.message; })
.finally(() => { blobLoading = false; });
.then((b) => {
blobUrl = URL.createObjectURL(b);
})
.catch((e) => {
blobError = e.message;
})
.finally(() => {
blobLoading = false;
});
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
@@ -82,18 +149,24 @@
});
$effect(() => {
textContent = '';
textError = '';
if (!file || (activeType !== 'text' && activeType !== 'markdown')) return;
textContent = "";
textError = "";
if (!file || (activeType !== "text" && activeType !== "markdown")) return;
textLoading = true;
authFetch(rawUrl)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then((t) => { textContent = t; })
.catch((e) => { textError = e.message; })
.finally(() => { textLoading = false; });
.then((t) => {
textContent = t;
})
.catch((e) => {
textError = e.message;
})
.finally(() => {
textLoading = false;
});
});
function close() {
@@ -101,11 +174,11 @@
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
if (e.key === "Escape") close();
}
function formatSize(bytes: number): string {
if (!bytes) return '';
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -115,48 +188,76 @@
<svelte:window onkeydown={handleKeydown} />
{#if file}
<div class="fixed inset-0 z-50 flex flex-col bg-neutral-950/95 backdrop-blur-sm">
<div class="flex items-center justify-between px-4 py-3 border-b border-neutral-700 shrink-0 bg-neutral-900">
<div
class="fixed inset-0 z-50 flex flex-col bg-neutral-950/95 backdrop-blur-sm"
>
<div
class="flex items-center justify-between px-4 py-3 border-b border-neutral-700 shrink-0 bg-neutral-900"
>
<div class="flex items-center gap-3 min-w-0">
<div class="text-sm font-medium text-white truncate">{file.name}</div>
{#if file.size_bytes}
<div class="text-xs text-neutral-500 shrink-0">{formatSize(file.size_bytes)}</div>
<div class="text-xs text-neutral-500 shrink-0">
{formatSize(file.size_bytes)}
</div>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0 ml-4">
<button
onclick={() => filesApi.download(file.id, file.name)}
class="flex items-center gap-1.5 text-xs text-neutral-300 hover:text-white bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Download
</button>
<div class="flex items-center gap-2 shrink-0 ml-4">
<button
onclick={() => {
if (file) filesApi.download(file.id, file.name);
}}
class="flex items-center gap-1.5 text-xs text-neutral-300 hover:text-white bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded transition-colors"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
></path></svg
>
Download
</button>
<button
onclick={close}
class="text-neutral-400 hover:text-white p-1.5 rounded hover:bg-neutral-700 transition-colors"
title="Close (Esc)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path></svg
>
</button>
</div>
</div>
<div class="flex-1 overflow-hidden flex items-center justify-center">
{#if activeType === 'pdf'}
{#if activeType === "pdf"}
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
<div class="text-red-400">Failed to load: {blobError}</div>
{:else}
<iframe
src={blobUrl}
title={file.name}
class="w-full h-full border-0"
<iframe src={blobUrl} title={file.name} class="w-full h-full border-0"
></iframe>
{/if}
{:else if activeType === 'image'}
<div class="w-full h-full overflow-auto flex items-center justify-center p-4">
{:else if activeType === "image"}
<div
class="w-full h-full overflow-auto flex items-center justify-center p-4"
>
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
@@ -169,8 +270,7 @@
/>
{/if}
</div>
{:else if activeType === 'video'}
{:else if activeType === "video"}
<div class="w-full h-full flex items-center justify-center p-4">
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
@@ -185,56 +285,102 @@
></video>
{/if}
</div>
{:else if activeType === 'audio'}
{:else if activeType === "audio"}
<div class="flex flex-col items-center justify-center gap-6 p-8">
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
<div class="text-red-400">Failed to load: {blobError}</div>
{:else}
<div class="w-24 h-24 rounded-full bg-neutral-800 border border-neutral-600 flex items-center justify-center">
<svg class="w-10 h-10 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path></svg>
<div
class="w-24 h-24 rounded-full bg-neutral-800 border border-neutral-600 flex items-center justify-center"
>
<svg
class="w-10 h-10 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
></path></svg
>
</div>
<div class="text-neutral-300 text-sm font-medium">{file.name}</div>
<audio src={blobUrl} controls class="w-80 max-w-full"></audio>
{/if}
</div>
{:else if activeType === 'markdown'}
{:else if activeType === "markdown"}
<div class="w-full h-full overflow-auto">
{#if textLoading}
<div class="flex items-center justify-center h-full text-neutral-400">Loading…</div>
<div
class="flex items-center justify-center h-full text-neutral-400"
>
Loading…
</div>
{:else if textError}
<div class="flex items-center justify-center h-full text-red-400">Failed to load: {textError}</div>
<div class="flex items-center justify-center h-full text-red-400">
Failed to load: {textError}
</div>
{:else}
<div class="max-w-3xl mx-auto px-8 py-8">
<Markdown content={textContent} />
</div>
{/if}
</div>
{:else if activeType === 'text'}
{:else if activeType === "text"}
<div class="w-full h-full overflow-auto">
{#if textLoading}
<div class="flex items-center justify-center h-full text-neutral-400">Loading…</div>
<div
class="flex items-center justify-center h-full text-neutral-400"
>
Loading…
</div>
{:else if textError}
<div class="flex items-center justify-center h-full text-red-400">Failed to load: {textError}</div>
<div class="flex items-center justify-center h-full text-red-400">
Failed to load: {textError}
</div>
{:else}
<pre class="p-6 text-sm text-neutral-200 font-mono leading-relaxed whitespace-pre-wrap break-words">{textContent}</pre>
<pre
class="p-6 text-sm text-neutral-200 font-mono leading-relaxed whitespace-pre-wrap wrap-break-word">{textContent}</pre>
{/if}
</div>
{:else}
<div class="flex flex-col items-center justify-center gap-4 text-neutral-400">
<svg class="w-16 h-16 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>
<div
class="flex flex-col items-center justify-center gap-4 text-neutral-400"
>
<svg
class="w-16 h-16 text-neutral-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
></path></svg
>
<p class="text-sm">No preview available for this file type.</p>
<button
onclick={() => file && filesApi.download(file.id, file.name)}
<button
onclick={() => file && filesApi.download(file.id, file.name)}
class="flex items-center gap-2 text-sm text-white bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Download {file.name}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
></path></svg
>
Download {file?.name}
</button>
</div>
{/if}

View File

@@ -1,5 +1,11 @@
<script lang="ts">
let { isOpen = $bindable(false), title, children, onClose = () => {} } = $props();
let {
isOpen = $bindable(false),
title,
children,
maxWidth = "max-w-2xl",
onClose = () => {},
} = $props();
function close() {
isOpen = false;
@@ -8,19 +14,40 @@
</script>
{#if isOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm overflow-y-auto">
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm overflow-y-auto"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0" onclick={close}></div>
<div class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-2xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-neutral-700 shrink-0">
<div
class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full {maxWidth} max-h-[90vh] flex flex-col"
>
<div
class="flex items-center justify-between p-4 border-b border-neutral-700 shrink-0"
>
<h2 class="text-xl font-semibold text-white">{title}</h2>
<button onclick={close} class="text-neutral-400 hover:text-white transition-colors p-1 rounded-md hover:bg-neutral-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
<button
onclick={close}
aria-label="Close"
class="text-neutral-400 hover:text-white transition-colors p-1 rounded-md hover:bg-neutral-700"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path></svg
>
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
{@render children()}
</div>

View File

@@ -70,8 +70,10 @@ export interface Card {
description: string;
priority: string;
color: string;
due_date: string;
due_date?: string;
assignees: string[];
estimated_minutes?: number;
actual_minutes?: number;
subtasks: Subtask[];
position: number;
created_by: string;
@@ -150,6 +152,7 @@ export interface Webhook {
type: string;
url: string;
status: string;
active?: boolean;
last_triggered?: string;
created_by: string;
created_at: string;
@@ -189,5 +192,8 @@ export interface ChatMessage {
user_id: string;
user_name: string;
content: string;
reply_to?: string;
edited_at?: string;
deleted?: boolean;
created_at: string;
}

View File

@@ -12,6 +12,7 @@
authStore.user?.name?.charAt(0).toUpperCase() ?? "U",
);
let unreadCount = $state(0);
let isMobileMenuOpen = $state(false);
onMount(async () => {
await authStore.init();
@@ -162,8 +163,9 @@
<!-- Mobile menu button -->
<button
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
class="md:hidden text-neutral-400 hover:text-white transition-colors p-2"
aria-label="Open menu"
aria-label="Toggle menu"
>
<svg
class="h-6 w-6"
@@ -213,6 +215,63 @@
</div>
</header>
{#if isMobileMenuOpen}
<div
class="md:hidden absolute top-16 left-0 right-0 max-h-[calc(100vh-4rem)] overflow-y-auto bg-neutral-800 border-b border-neutral-700 shadow-2xl z-50 flex flex-col pointer-events-auto"
>
<a
href="/"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium"
>Dashboard</a
>
<a
href="/projects"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium"
>Projects</a
>
<a
href="/calendar"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium"
>Calendar</a
>
<a
href="/files"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium"
>Files</a
>
<a
href="/docs"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium"
>Docs</a
>
<a
href="/api-docs"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium"
>API Docs</a
>
<a
href="/settings/user"
onclick={() => (isMobileMenuOpen = false)}
class="px-6 py-4 border-b border-neutral-700 text-neutral-200 hover:bg-neutral-700 transition-colors font-medium border-t-4 border-t-neutral-900"
>Settings ({authStore.user?.name})</a
>
<button
onclick={() => {
isMobileMenuOpen = false;
logout();
}}
class="text-left px-6 py-4 text-red-400 hover:bg-neutral-700 font-medium"
>Log out</button
>
</div>
{/if}
<!-- Main Content -->
<main class="flex-1 flex flex-col min-w-0 overflow-hidden relative">
<div class="flex-1 overflow-auto">

View File

@@ -182,10 +182,13 @@
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={() => (showNewTeam = false)}
onkeydown={(e) => e.key === "Escape" && (showNewTeam = false)}
role="dialog"
aria-label="Create Team dialog"
tabindex="-1"
>
<div
class="bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
>
<div
class="flex items-center justify-between p-4 border-b border-neutral-700"

View File

@@ -139,6 +139,7 @@
let isModalOpen = $state(false);
let activeColumnIdForNewTask = $state<string | null>(null);
let originalColumnIdForTask = $state<string | null>(null);
let editingCardId = $state<string | null>(null);
let newTask = $state({
@@ -148,6 +149,8 @@
color: "neutral",
dueDate: "",
assignees: [] as string[],
estimatedMinutes: "",
actualMinutes: "",
subtasks: [] as { id: number; text: string; done: boolean }[],
});
@@ -200,6 +203,7 @@
function openCreateTaskModal(columnId: string) {
activeColumnIdForNewTask = columnId;
originalColumnIdForTask = columnId;
editingCardId = null;
newTask = {
title: "",
@@ -208,6 +212,8 @@
color: "neutral",
dueDate: "",
assignees: [],
estimatedMinutes: "",
actualMinutes: "",
subtasks: [],
};
assigneeInput = "";
@@ -217,6 +223,7 @@
function openEditTaskModal(columnId: string, card: LocalCard) {
activeColumnIdForNewTask = columnId;
originalColumnIdForTask = columnId;
editingCardId = card.id;
newTask = {
title: card.title,
@@ -225,6 +232,8 @@
color: card.color || "neutral",
dueDate: card.due_date ? card.due_date.split("T")[0] : "",
assignees: [...(card.assignees || [])],
estimatedMinutes: card.estimated_minutes?.toString() || "",
actualMinutes: card.actual_minutes?.toString() || "",
subtasks: card.subtasks.map((st) => ({ ...st })),
};
assigneeInput = "";
@@ -257,15 +266,42 @@
color: newTask.color,
due_date: newTask.dueDate,
assignees: newTask.assignees,
estimated_minutes: newTask.estimatedMinutes
? parseInt(newTask.estimatedMinutes)
: undefined,
actual_minutes: newTask.actualMinutes
? parseInt(newTask.actualMinutes)
: undefined,
subtasks: newTask.subtasks.map((st) => ({
id: st.id,
text: st.text,
done: st.done,
})),
});
targetCol.cards = targetCol.cards.map((card) =>
card.id === editingCardId
? {
if (originalColumnIdForTask === activeColumnIdForNewTask) {
targetCol.cards = targetCol.cards.map((card) =>
card.id === editingCardId
? {
...card,
...updated,
subtasks: (updated.subtasks ?? []).map((st) => ({
id: st.id,
text: st.text,
done: st.done,
})),
}
: card,
);
} else {
const oldCol = columns.find((c) => c.id === originalColumnIdForTask);
if (oldCol) {
const cardIndex = oldCol.cards.findIndex(
(c) => c.id === editingCardId,
);
if (cardIndex !== -1) {
const [card] = oldCol.cards.splice(cardIndex, 1);
const formattedCard = {
...card,
...updated,
subtasks: (updated.subtasks ?? []).map((st) => ({
@@ -273,9 +309,18 @@
text: st.text,
done: st.done,
})),
}
: card,
);
};
targetCol.cards.push(formattedCard);
await cardsApi
.move(
editingCardId,
activeColumnIdForNewTask!,
targetCol.cards.length - 1,
)
.catch(() => {});
}
}
}
} else {
const created = await boardApi.createCard(
boardId,
@@ -287,6 +332,12 @@
color: newTask.color,
due_date: newTask.dueDate || "",
assignees: newTask.assignees,
estimated_minutes: newTask.estimatedMinutes
? parseInt(newTask.estimatedMinutes)
: undefined,
actual_minutes: newTask.actualMinutes
? parseInt(newTask.actualMinutes)
: undefined,
},
);
targetCol.cards = [...targetCol.cards, { ...created, subtasks: [] }];
@@ -561,8 +612,8 @@
ondragstart={(e) => handleDragStart(card.id, column.id, e)}
class="bg-neutral-750 p-4 rounded-md border border-neutral-600 shadow-sm {isArchived
? 'cursor-default'
: 'cursor-grab active:cursor-grabbing'} hover:border-neutral-500 transition-colors flex flex-col gap-2 group relative overflow-hidden"
role="listitem"
: 'cursor-pointer hover:border-neutral-500'} transition-colors flex flex-col gap-2 group relative overflow-hidden"
role="button"
onclick={() =>
!isArchived && openEditTaskModal(column.id, card)}
onkeydown={(e) =>
@@ -642,6 +693,20 @@
)}
</div>
{/if}
{#if card.estimated_minutes || card.actual_minutes}
<div
class="flex items-center text-[10px] font-medium text-neutral-400 bg-neutral-700/50 px-1.5 py-1 rounded gap-1"
>
<Icon
icon="lucide:clock"
class="w-3 h-3 text-neutral-500"
/>
<span
>{card.actual_minutes || 0}m / {card.estimated_minutes ||
"?"}m</span
>
</div>
{/if}
</div>
{#if card.assignees && card.assignees.length > 0}
<div class="flex -space-x-1 overflow-hidden">
@@ -696,6 +761,8 @@
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Priority</th>
<th class="px-4 py-3 font-medium">Due Date</th>
<th class="px-4 py-3 font-medium">Est. (m)</th>
<th class="px-4 py-3 font-medium">Act. (m)</th>
<th class="px-4 py-3 font-medium">Assignees</th>
<th class="px-4 py-3 font-medium">Subtasks</th>
</tr>
@@ -751,6 +818,20 @@
<span class="text-neutral-600"></span>
{/if}
</td>
<td class="px-4 py-3 text-neutral-400 text-xs">
{#if card.estimated_minutes != null}
{card.estimated_minutes}
{:else}
<span class="text-neutral-600"></span>
{/if}
</td>
<td class="px-4 py-3 text-neutral-400 text-xs">
{#if card.actual_minutes != null}
{card.actual_minutes}
{:else}
<span class="text-neutral-600"></span>
{/if}
</td>
<td class="px-4 py-3">
{#if card.assignees?.length}
<div class="flex -space-x-1">
@@ -777,7 +858,7 @@
</tr>
{:else}
<tr
><td colspan="6" class="px-4 py-8 text-center text-neutral-500"
><td colspan="8" class="px-4 py-8 text-center text-neutral-500"
>No tasks yet.</td
></tr
>
@@ -793,7 +874,7 @@
? new Date(
Math.min(
...datedCards.map((c) =>
new Date(c.created_at || c.due_date).getTime(),
new Date(c.created_at || c.due_date!).getTime(),
),
now.getTime() - 7 * 86400000,
),
@@ -802,7 +883,7 @@
{@const ganttEnd = datedCards.length
? new Date(
Math.max(
...datedCards.map((c) => new Date(c.due_date).getTime()),
...datedCards.map((c) => new Date(c.due_date!).getTime()),
now.getTime() + 7 * 86400000,
),
)
@@ -847,8 +928,8 @@
</div>
{:else}
{#each datedCards as card (card.id)}
{@const created = new Date(card.created_at || card.due_date)}
{@const due = new Date(card.due_date)}
{@const created = new Date(card.created_at || card.due_date!)}
{@const due = new Date(card.due_date!)}
{@const startOffset = Math.max(
0,
((created.getTime() - ganttStart.getTime()) /
@@ -868,6 +949,12 @@
class="w-56 shrink-0 px-4 py-3 border-r border-neutral-700 flex items-center gap-2 cursor-pointer"
onclick={() =>
!isArchived && openEditTaskModal(card.columnId, card)}
onkeydown={(e) =>
e.key === "Enter" &&
!isArchived &&
openEditTaskModal(card.columnId, card)}
role="button"
tabindex="0"
>
{#if card.color && card.color !== "neutral"}<div
class="w-1 h-5 rounded-full {colorClasses[card.color]}"
@@ -936,6 +1023,12 @@
class="bg-neutral-800 border border-neutral-700 rounded-lg p-3 hover:border-neutral-600 transition-colors cursor-pointer group"
onclick={() =>
!isArchived && openEditTaskModal(column.id, card)}
onkeydown={(e) =>
e.key === "Enter" &&
!isArchived &&
openEditTaskModal(column.id, card)}
role="button"
tabindex="0"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
@@ -971,7 +1064,7 @@
{#if card.due_date}
<span class="flex items-center gap-1">
<Icon icon="lucide:calendar" class="w-3 h-3" />
{new Date(card.due_date).toLocaleDateString(
{new Date(card.due_date!).toLocaleDateString(
"en-US",
{ month: "short", day: "numeric" },
)}
@@ -1024,225 +1117,338 @@
<Modal
bind:isOpen={isModalOpen}
title={editingCardId ? "Edit Task" : "Create New Task"}
maxWidth="max-w-5xl"
>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
<div class="md:col-span-8">
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
<div class="flex flex-col md:flex-row gap-8">
<!-- Left Main Content Area -->
<div class="flex-1 space-y-6">
<div>
<label
for="task-title-input"
class="block text-sm font-semibold text-neutral-300 mb-1.5"
>Task Title</label
>
<input
id="task-title-input"
type="text"
bind:value={newTask.title}
placeholder="Task title"
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
placeholder="Enter a descriptive title..."
class="block w-full px-4 py-3 text-lg font-medium border border-neutral-600 rounded-lg shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-neutral-700/50 text-white transition-all"
/>
</div>
<div class="md:col-span-4 grid grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Priority</label
>
<select
bind:value={newTask.priority}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
>
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Urgent">Urgent</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Color</label
>
<select
bind:value={newTask.color}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
>
<option value="neutral">None</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="yellow">Yellow</option>
<option value="purple">Purple</option>
</select>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-neutral-300"
>Description</label
>
<div class="flex items-center space-x-2">
<button
class="text-xs font-medium px-2 py-1 rounded {previewMarkdown
? 'text-neutral-400 hover:bg-neutral-700'
: 'bg-neutral-700 text-white'}"
onclick={() => (previewMarkdown = false)}>Write</button
>
<button
class="text-xs font-medium px-2 py-1 rounded {previewMarkdown
? 'bg-neutral-700 text-white'
: 'text-neutral-400 hover:bg-neutral-700'}"
onclick={() => (previewMarkdown = true)}>Preview</button
>
</div>
</div>
<div
class="border border-neutral-600 rounded-md bg-neutral-700 min-h-[120px]"
>
{#if previewMarkdown}
<div class="p-4 h-full">
{#if newTask.description}
<Markdown content={newTask.description} files={projectFiles} />
{:else}
<p class="text-neutral-500 italic text-sm">
No description provided.
</p>
{/if}
</div>
{:else}
<textarea
bind:value={newTask.description}
placeholder="Supports Markdown format..."
class="block w-full h-full min-h-[120px] p-3 border-0 bg-transparent text-white placeholder-neutral-500 focus:ring-0 sm:text-sm resize-y"
></textarea>
{/if}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Due Date</label
>
<input
type="date"
bind:value={newTask.dueDate}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div class="relative">
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Assignees</label
>
{#if newTask.assignees.length > 0}
<div class="flex flex-wrap gap-1 mb-2">
{#each newTask.assignees as email}
<span
class="inline-flex items-center gap-1 bg-blue-600/20 text-blue-300 border border-blue-500/30 text-xs px-2 py-0.5 rounded-full"
>
{email}
<button
onclick={() => removeAssignee(email)}
class="hover:text-white ml-0.5"
>
<Icon icon="lucide:x" class="w-3 h-3" />
</button>
</span>
{/each}
</div>
{/if}
<input
type="text"
value={assigneeInput}
oninput={handleAssigneeInput}
placeholder="Type @ to search users..."
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
{#if showUserDropdown && userSearchResults.length > 0}
<div
class="absolute z-50 w-full mt-1 bg-neutral-800 border border-neutral-600 rounded-md shadow-lg overflow-hidden"
<div class="flex items-center justify-between mb-2">
<label
for="task-desc-input"
class="block text-sm font-semibold text-neutral-300"
>Description</label
>
{#each userSearchResults as user}
<button
type="button"
onclick={() => selectUser(user)}
class="w-full flex items-center gap-3 px-3 py-2 hover:bg-neutral-700 transition-colors text-left"
>
<div
class="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center text-xs font-bold text-white uppercase shrink-0"
>
{user.name.charAt(0)}
</div>
<div class="min-w-0">
<div class="text-sm font-medium text-white truncate">
{user.name}
</div>
<div class="text-xs text-neutral-400 truncate">
{user.email}
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2"
>Subtasks</label
>
<ul class="space-y-2 mb-3">
{#each newTask.subtasks as subtask, i}
<li class="flex items-center gap-2 text-sm text-neutral-200">
<input
type="checkbox"
bind:checked={newTask.subtasks[i].done}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-neutral-600 rounded bg-neutral-700"
/>
<span class={subtask.done ? "line-through text-neutral-500" : ""}
>{subtask.text}</span
<div
class="flex items-center bg-neutral-800 rounded-md border border-neutral-700 p-0.5"
>
<button
class="text-xs font-medium px-3 py-1.5 rounded-md transition-colors {previewMarkdown
? 'text-neutral-400 hover:text-neutral-300'
: 'bg-neutral-700 text-white shadow-sm'}"
onclick={() => (previewMarkdown = false)}>Write</button
>
<button
class="ml-auto text-neutral-500 hover:text-red-400"
onclick={() =>
(newTask.subtasks = newTask.subtasks.filter(
(st) => st.id !== subtask.id,
))}
class="text-xs font-medium px-3 py-1.5 rounded-md transition-colors {previewMarkdown
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-400 hover:text-neutral-300'}"
onclick={() => (previewMarkdown = true)}>Preview</button
>
<Icon icon="lucide:x" class="w-4 h-4" />
</button>
</li>
{/each}
</ul>
<form onsubmit={addSubtask} class="flex gap-2">
<input
type="text"
bind:value={newSubtaskText}
placeholder="Add a subtask..."
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
<button
type="submit"
class="bg-neutral-600 hover:bg-neutral-500 text-white px-3 py-2 rounded-md transition-colors"
</div>
</div>
<div
class="border border-neutral-600 rounded-lg bg-neutral-700/30 overflow-hidden transition-all focus-within:ring-1 focus-within:ring-blue-500 focus-within:border-blue-500 min-h-[200px]"
>
Add
</button>
</form>
{#if previewMarkdown}
<div class="p-5 h-full prose prose-invert max-w-none text-sm">
{#if newTask.description}
<Markdown content={newTask.description} files={projectFiles} />
{:else}
<p class="text-neutral-500 italic">No description provided.</p>
{/if}
</div>
{:else}
<textarea
id="task-desc-input"
bind:value={newTask.description}
placeholder="Add context, notes, or criteria... (Markdown supported)"
class="block w-full h-full min-h-[200px] p-4 text-sm border-0 bg-transparent text-white placeholder-neutral-500 focus:ring-0 resize-y"
></textarea>
{/if}
</div>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<label
for="new-subtask-input"
class="block text-sm font-semibold text-neutral-300">Subtasks</label
>
<span
class="text-xs font-medium text-neutral-500 bg-neutral-800 px-2 py-0.5 rounded-full border border-neutral-700"
>
{newTask.subtasks.filter((s) => s.done).length} / {newTask.subtasks
.length}
</span>
</div>
<div
class="space-y-2 mb-3 max-h-48 overflow-y-auto pr-2 custom-scrollbar"
>
{#each newTask.subtasks as subtask, i}
<div
class="group flex items-center gap-3 p-2 hover:bg-neutral-700/30 rounded-lg transition-colors border border-transparent hover:border-neutral-700/50"
>
<input
type="checkbox"
bind:checked={newTask.subtasks[i].done}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-neutral-500 rounded bg-neutral-800 cursor-pointer"
/>
<span
class="text-sm flex-1 {subtask.done
? 'line-through text-neutral-500'
: 'text-neutral-200'}">{subtask.text}</span
>
<button
class="text-neutral-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded"
onclick={() =>
(newTask.subtasks = newTask.subtasks.filter(
(st) => st.id !== subtask.id,
))}
title="Remove subtask"
>
<Icon icon="lucide:x" class="w-4 h-4" />
</button>
</div>
{:else}
<div
class="text-center py-4 border border-dashed border-neutral-700 rounded-lg bg-neutral-800/20"
>
<p class="text-xs text-neutral-500 italic">
Break this task into smaller steps.
</p>
</div>
{/each}
</div>
<form onsubmit={addSubtask} class="relative">
<input
id="new-subtask-input"
type="text"
bind:value={newSubtaskText}
placeholder="Add a new subtask..."
class="block w-full pl-4 pr-16 py-2.5 border border-neutral-600 rounded-lg placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm bg-neutral-700/50 text-white"
/>
<button
type="submit"
disabled={!newSubtaskText.trim()}
class="absolute right-1.5 top-1.5 bottom-1.5 bg-neutral-600 hover:bg-neutral-500 disabled:opacity-50 disabled:hover:bg-neutral-600 text-white px-3 rounded-md transition-colors text-xs font-medium"
>
Add
</button>
</form>
</div>
</div>
<div class="flex justify-end pt-4 border-t border-neutral-700 gap-3">
<button
onclick={() => (isModalOpen = false)}
class="bg-transparent hover:bg-neutral-700 text-neutral-300 font-medium py-2 px-4 rounded-md border border-neutral-600 transition-colors text-sm"
>
Cancel
</button>
<button
onclick={saveNewTask}
disabled={!newTask.title.trim()}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingCardId ? "Save Changes" : "Create Task"}
</button>
<!-- Right Sidebar: Metadata & Attributes -->
<div
class="w-full md:w-80 shrink-0 flex flex-col gap-5 border-t md:border-t-0 md:border-l border-neutral-700 pt-6 md:pt-0 md:pl-6"
>
<div class="space-y-4">
<h3 class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
Properties
</h3>
<!-- Status (Column) mapping if we wanted to change status - currently tied to activeColumnIdForNewTask -->
{#if editingCardId && activeColumnIdForNewTask}
<div>
<label
class="text-xs font-semibold text-neutral-400 mb-1.5 flex items-center gap-1.5"
><Icon icon="lucide:columns" class="w-3.5 h-3.5" /> Stage</label
>
<select
bind:value={activeColumnIdForNewTask}
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white font-medium"
>
{#each columns as column}
<option value={column.id}>{column.title}</option>
{/each}
</select>
</div>
{/if}
<div class="grid grid-cols-2 gap-3">
<div>
<label
class="text-xs font-semibold text-neutral-400 mb-1.5 flex items-center gap-1.5"
><Icon icon="lucide:target" class="w-3.5 h-3.5" /> Priority</label
>
<select
bind:value={newTask.priority}
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white"
>
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Urgent">Urgent</option>
</select>
</div>
<div>
<label
class="text-xs font-semibold text-neutral-400 mb-1.5 flex items-center gap-1.5"
><Icon icon="lucide:palette" class="w-3.5 h-3.5" /> Label</label
>
<select
bind:value={newTask.color}
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white"
>
<option value="neutral">None</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="yellow">Yellow</option>
<option value="purple">Purple</option>
</select>
</div>
</div>
<div>
<label
class="text-xs font-semibold text-neutral-400 mb-1.5 flex items-center gap-1.5"
><Icon icon="lucide:calendar" class="w-3.5 h-3.5" /> Due Date</label
>
<input
type="date"
bind:value={newTask.dueDate}
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label
class="text-xs font-semibold text-neutral-400 mb-1.5 flex items-center gap-1.5"
><Icon icon="lucide:clock" class="w-3.5 h-3.5" /> Est. (m)</label
>
<input
type="number"
bind:value={newTask.estimatedMinutes}
min="0"
placeholder="ex: 60"
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white placeholder-neutral-500"
/>
</div>
<div>
<label
class="text-xs font-semibold text-neutral-400 mb-1.5 flex items-center gap-1.5"
><Icon icon="lucide:clock-4" class="w-3.5 h-3.5" /> Act. (m)</label
>
<input
type="number"
bind:value={newTask.actualMinutes}
min="0"
placeholder="ex: 45"
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white placeholder-neutral-500"
/>
</div>
</div>
<div class="pt-2">
<label
class="text-xs font-semibold text-neutral-400 mb-2 flex items-center gap-1.5"
><Icon icon="lucide:users" class="w-3.5 h-3.5" /> Assignees</label
>
{#if newTask.assignees.length > 0}
<div class="flex flex-col gap-1.5 mb-3">
{#each newTask.assignees as email}
<div
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700 px-2.5 py-1.5 rounded-md"
>
<div class="flex items-center gap-2 overflow-hidden">
<div
class="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center text-[9px] font-bold text-white uppercase shrink-0"
>
{email.charAt(0)}
</div>
<span class="text-xs text-neutral-300 truncate"
>{email}</span
>
</div>
<button
onclick={() => removeAssignee(email)}
class="text-neutral-500 hover:text-red-400 transition-colors shrink-0 ml-2"
title="Remove assignee"
>
<Icon icon="lucide:x" class="w-3.5 h-3.5" />
</button>
</div>
{/each}
</div>
{/if}
<div class="relative">
<input
type="text"
value={assigneeInput}
oninput={handleAssigneeInput}
placeholder="Type @ to search..."
class="w-full px-3 py-2 border border-neutral-600 rounded-lg focus:outline-none focus:border-blue-500 text-sm bg-neutral-700/80 text-white placeholder-neutral-500"
/>
{#if showUserDropdown && userSearchResults.length > 0}
<div
class="absolute z-50 w-full mt-1 bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl overflow-hidden drop-shadow-2xl"
>
{#each userSearchResults as user}
<button
type="button"
onclick={() => selectUser(user)}
class="w-full flex items-center gap-3 px-3 py-2 hover:bg-neutral-700 transition-colors text-left"
>
<div
class="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-[10px] font-bold text-white uppercase shrink-0"
>
{user.name.charAt(0)}
</div>
<div class="min-w-0">
<div class="text-xs font-semibold text-white truncate">
{user.name}
</div>
<div class="text-[10px] text-neutral-400 truncate">
{user.email}
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Action Bar -->
<div
class="mt-8 pt-5 border-t border-neutral-700 flex justify-end gap-3 shrink-0"
>
<button
onclick={() => (isModalOpen = false)}
class="px-5 py-2.5 rounded-lg text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700/50 transition-colors focus:ring-2 focus:ring-neutral-600 focus:outline-none"
>
Cancel
</button>
<button
onclick={saveNewTask}
disabled={!newTask.title.trim()}
class="px-6 py-2.5 rounded-lg text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 shadow-md shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-95 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-neutral-800 focus:outline-none"
>
{editingCardId ? "Save Changes" : "Create Task"}
</button>
</div>
</Modal>
<Modal bind:isOpen={isShareModalOpen} title="Share & Visibility">

View File

@@ -64,7 +64,7 @@
.filter((c) => c.due_date)
.map((c) => ({
id: c.id,
date: c.due_date.split("T")[0],
date: c.due_date!.split("T")[0],
title: c.title,
time: "",
color: priorityColor[c.priority] ?? "blue",
@@ -129,11 +129,8 @@
<div class="flex items-center justify-between mb-6 shrink-0">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">
Organization Calendar
Personal Calendar
</h1>
<p class="text-neutral-400 mt-1">
Overview of all team events and milestones.
</p>
</div>
<div class="flex items-center space-x-4">
<button
@@ -156,10 +153,12 @@
<Modal bind:isOpen={isModalOpen} title="Add Event">
<form onsubmit={handleAddEvent} class="space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
<label
for="event-title"
class="block text-sm font-medium text-neutral-300 mb-1">Title</label
>
<input
id="event-title"
type="text"
bind:value={newEvent.title}
required
@@ -170,10 +169,12 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Date</label
<label
for="event-date"
class="block text-sm font-medium text-neutral-300 mb-1">Date</label
>
<input
id="event-date"
type="date"
bind:value={newEvent.date}
required
@@ -181,10 +182,12 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Time</label
<label
for="event-time"
class="block text-sm font-medium text-neutral-300 mb-1">Time</label
>
<input
id="event-time"
type="text"
bind:value={newEvent.time}
placeholder="e.g. 10:00 AM"
@@ -195,10 +198,12 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Color</label
<label
for="event-color"
class="block text-sm font-medium text-neutral-300 mb-1">Color</label
>
<select
id="event-color"
bind:value={newEvent.color}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
>
@@ -210,10 +215,12 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Team</label
<label
for="event-team"
class="block text-sm font-medium text-neutral-300 mb-1">Team</label
>
<select
id="event-team"
bind:value={newEvent.teamId}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
>
@@ -225,10 +232,13 @@
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-desc"
class="block text-sm font-medium text-neutral-300 mb-1"
>Description</label
>
<textarea
id="event-desc"
bind:value={newEvent.description}
placeholder="Optional description"
rows="2"

View File

@@ -121,6 +121,7 @@
<div class="flex items-center space-x-4">
<a
href="/"
aria-label="Back to home"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg

View File

@@ -18,14 +18,14 @@
let calendarEvents = $derived(
rawEvents.map((e) => {
const dt = new Date(e.start_time);
const dt = new Date(e.date + "T" + (e.time || "00:00"));
const hours = dt.getHours();
const minutes = dt.getMinutes().toString().padStart(2, "0");
const ampm = hours >= 12 ? "PM" : "AM";
const h = hours % 12 || 12;
return {
id: e.id,
date: e.start_time.split("T")[0],
date: e.date,
title: e.title,
time: `${h}:${minutes} ${ampm}`,
color: "blue",
@@ -50,14 +50,12 @@
saving = true;
error = "";
try {
const startTime = newEvent.time
? new Date(`${newEvent.date}T${newEvent.time}`).toISOString()
: new Date(`${newEvent.date}T00:00:00`).toISOString();
const created = await projectsApi.createEvent(projectId, {
title: newEvent.title,
description: newEvent.description,
start_time: startTime,
end_time: startTime,
date: newEvent.date,
time: newEvent.time,
color: "blue", // Hardcoded color since no color selector is present in this form yet
});
rawEvents = [...rawEvents, created];
isModalOpen = false;
@@ -85,6 +83,7 @@
<div class="flex items-center space-x-4">
<a
href="/projects"
aria-label="Back to projects"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
@@ -148,10 +147,12 @@
<p class="text-sm text-red-400">{error}</p>
{/if}
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
<label
for="event-title"
class="block text-sm font-medium text-neutral-300 mb-1">Title</label
>
<input
id="event-title"
type="text"
bind:value={newEvent.title}
required
@@ -160,10 +161,13 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-desc"
class="block text-sm font-medium text-neutral-300 mb-1"
>Description</label
>
<textarea
id="event-desc"
bind:value={newEvent.description}
rows="2"
placeholder="Optional description"
@@ -172,10 +176,13 @@
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-date"
class="block text-sm font-medium text-neutral-300 mb-1"
>Date</label
>
<input
id="event-date"
type="date"
bind:value={newEvent.date}
required
@@ -183,10 +190,13 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-time"
class="block text-sm font-medium text-neutral-300 mb-1"
>Time</label
>
<input
id="event-time"
type="time"
bind:value={newEvent.time}
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"

View File

@@ -112,6 +112,10 @@
let viewingFile = $state<FileItem | null>(null);
let fileInput: HTMLInputElement;
function focusNode(node: HTMLElement) {
node.focus();
}
</script>
<svelte:head>
@@ -129,6 +133,7 @@
<div class="flex items-center space-x-4">
<a
href="/projects"
aria-label="Back to projects"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg
@@ -219,7 +224,7 @@
bind:value={folderName}
placeholder="Folder name"
required
autofocus
use:focusNode
class="flex-1 px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<button

View File

@@ -85,6 +85,7 @@
<div class="flex items-center space-x-4 mb-2">
<a
href="/projects"
aria-label="Back to projects"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"

View File

@@ -30,8 +30,8 @@
try {
const created = await projectsApi.createWebhook(projectId, {
name: newWebhook.name,
type: "discord",
url: newWebhook.url,
events: ["*"],
});
webhookList = [...webhookList, created];
isModalOpen = false;
@@ -229,7 +229,7 @@
</div>
<div class="text-xs text-neutral-500 mt-1 capitalize">
{wtype} • Last: {formatLastTriggered(
webhook.last_triggered,
webhook.last_triggered || "",
)}
</div>
</div>
@@ -297,10 +297,13 @@
<form onsubmit={addWebhook} class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="webhook-type"
class="block text-sm font-medium text-neutral-300 mb-1"
>Service Type</label
>
<select
id="webhook-type"
bind:value={newWebhook.type}
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
@@ -313,10 +316,12 @@
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Name</label
<label
for="webhook-name"
class="block text-sm font-medium text-neutral-300 mb-1">Name</label
>
<input
id="webhook-name"
type="text"
bind:value={newWebhook.name}
required
@@ -326,10 +331,13 @@
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="webhook-url"
class="block text-sm font-medium text-neutral-300 mb-1"
>Payload URL</label
>
<input
id="webhook-url"
type="url"
bind:value={newWebhook.url}
required
@@ -339,10 +347,13 @@
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="webhook-secret"
class="block text-sm font-medium text-neutral-300 mb-1"
>Secret Token (Optional)</label
>
<input
id="webhook-secret"
type="password"
bind:value={newWebhook.secret}
placeholder="Used to sign webhook payloads"

View File

@@ -19,6 +19,7 @@
let passwordSuccess = $state("");
let avatarError = $state("");
let avatarSuccess = $state("");
let avatarCacheBust = $state("");
// --- API Keys state ---
const ALL_SCOPES = [
@@ -74,6 +75,7 @@
try {
const updated = await usersApi.uploadAvatar(file);
authStore.setUser(updated);
avatarCacheBust = "?t=" + Date.now();
avatarSuccess = "Avatar updated successfully.";
} catch (err: unknown) {
avatarError =
@@ -253,7 +255,7 @@
<div class="shrink-0">
{#if authStore.user?.avatar_url}
<img
src={authStore.user.avatar_url}
src="{authStore.user.avatar_url}{avatarCacheBust}"
alt="Avatar"
class="h-16 w-16 rounded-full object-cover shadow-inner"
/>
@@ -660,7 +662,7 @@
{:else}
{#each apiKeyList as key (key.id)}
<div
class="px-6 py-4 flex items-start justify-between gap-4 group hover:bg-white/[0.02] transition-colors"
class="px-6 py-4 flex items-start justify-between gap-4 group hover:bg-white/2 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1.5">

View File

@@ -66,7 +66,7 @@
.filter((c) => c.due_date)
.map((c) => ({
id: c.id,
date: c.due_date.split("T")[0],
date: c.due_date!.split("T")[0],
title: c.title,
projectName: projectData[i].name,
projectId: projectData[i].id,
@@ -147,7 +147,7 @@
class="bg-neutral-800 rounded-xl border border-neutral-700 shadow-sm overflow-hidden shrink-0 relative"
>
<div
class="h-32 bg-gradient-to-r from-blue-900/40 to-purple-900/40 relative"
class="h-32 bg-linear-to-r from-blue-900/40 to-purple-900/40 relative"
>
{#if team.banner_url}
<img
@@ -203,7 +203,7 @@
</div>
</div>
<div class="flex items-center space-x-3 pb-2">
<div class="flex flex-wrap items-center gap-3 pb-2 mt-4 md:mt-0">
<a
href="/team/{teamId}/calendar"
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors flex items-center gap-2 text-sm"

View File

@@ -70,7 +70,7 @@
.filter((c) => c.due_date)
.map((c) => ({
id: c.id,
date: c.due_date.split("T")[0],
date: c.due_date!.split("T")[0],
title: c.title,
time: "",
color: priorityColor[c.priority] ?? "blue",
@@ -128,6 +128,7 @@
<div class="flex items-center space-x-4">
<a
href="/team/{teamId}"
aria-label="Back to Team"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
@@ -187,10 +188,12 @@
<p class="text-sm text-red-400">{error}</p>
{/if}
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
<label
for="event-title"
class="block text-sm font-medium text-neutral-300 mb-1">Title</label
>
<input
id="event-title"
type="text"
bind:value={newEvent.title}
required
@@ -199,10 +202,13 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-desc"
class="block text-sm font-medium text-neutral-300 mb-1"
>Description</label
>
<textarea
id="event-desc"
bind:value={newEvent.description}
rows="2"
placeholder="Optional description"
@@ -211,10 +217,13 @@
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-date"
class="block text-sm font-medium text-neutral-300 mb-1"
>Date</label
>
<input
id="event-date"
type="date"
bind:value={newEvent.date}
required
@@ -222,10 +231,13 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="event-time"
class="block text-sm font-medium text-neutral-300 mb-1"
>Time</label
>
<input
id="event-time"
type="text"
bind:value={newEvent.time}
placeholder="e.g. 10:00 AM"
@@ -234,10 +246,12 @@
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Color</label
<label
for="event-color"
class="block text-sm font-medium text-neutral-300 mb-1">Color</label
>
<select
id="event-color"
bind:value={newEvent.color}
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>

View File

@@ -15,6 +15,8 @@
let loadingMore = $state(false);
let hasMore = $state(true);
let sending = $state(false);
let replyingTo = $state<ChatMessage | null>(null);
let editingMessage = $state<ChatMessage | null>(null);
let ws: WebSocket | null = null;
let wsConnected = $state(false);
@@ -87,6 +89,28 @@
}
}
if (type === "edit" && msg.message_id && msg.content) {
const targetIdx = messages.findIndex((m) => m.id === msg.message_id);
if (targetIdx !== -1) {
messages[targetIdx] = {
...messages[targetIdx],
content: msg.content as string,
edited_at: msg.edited_at as string,
};
}
}
if (type === "delete" && msg.message_id) {
const targetIdx = messages.findIndex((m) => m.id === msg.message_id);
if (targetIdx !== -1) {
messages[targetIdx] = {
...messages[targetIdx],
content: "",
deleted: true,
};
}
}
if (
type === "typing" &&
typeof msg.user_id === "string" &&
@@ -156,14 +180,69 @@
const content = newMessage.trim();
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
sending = true;
ws.send(JSON.stringify({ type: "message", content }));
if (editingMessage) {
ws.send(
JSON.stringify({
type: "edit",
content,
message_id: editingMessage.id,
}),
);
editingMessage = null;
} else {
const payload: any = { type: "message", content };
if (replyingTo) {
payload.reply_to = replyingTo.id;
replyingTo = null;
}
ws.send(JSON.stringify(payload));
}
newMessage = "";
sending = false;
shouldAutoScroll = true;
requestAnimationFrame(() => inputEl?.focus());
}
function cancelAction() {
replyingTo = null;
if (editingMessage) {
editingMessage = null;
newMessage = "";
}
inputEl?.focus();
}
function startEdit(msg: ChatMessage) {
editingMessage = msg;
replyingTo = null;
newMessage = msg.content;
inputEl?.focus();
}
function startReply(msg: ChatMessage) {
replyingTo = msg;
editingMessage = null;
inputEl?.focus();
}
function deleteMessage(msgId: string) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "delete", message_id: msgId }));
}
}
function resolveReplyMessage(replyId?: string): ChatMessage | undefined {
if (!replyId) return undefined;
return messages.find((m) => m.id === replyId);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
cancelAction();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
@@ -333,8 +412,8 @@
>
<div
class="w-1.5 h-1.5 rounded-full {wsConnected
? 'bg-emerald-400 shadow-[0_0_6px_theme(colors.emerald.400)]'
: 'bg-red-400 shadow-[0_0_6px_theme(colors.red.400)]'}"
? 'bg-emerald-400 shadow-[0_0_6px_var(--color-emerald-400)]'
: 'bg-red-400 shadow-[0_0_6px_var(--color-red-400)]'}"
></div>
<span
class="text-[10px] font-medium {wsConnected
@@ -368,7 +447,7 @@
{:else if messages.length === 0}
<div class="flex flex-col items-center justify-center h-64 text-center">
<div
class="w-20 h-20 rounded-2xl bg-gradient-to-br from-blue-600/20 to-purple-600/20 border border-blue-500/20 flex items-center justify-center mb-5"
class="w-20 h-20 rounded-2xl bg-linear-to-br from-blue-600/20 to-purple-600/20 border border-blue-500/20 flex items-center justify-center mb-5"
>
<Icon icon="lucide:message-circle" class="w-9 h-9 text-blue-400" />
</div>
@@ -401,8 +480,9 @@
{#each messages as msg, idx}
{@const isMe = msg.user_id === myId}
{@const showHeader = shouldShowHeader(idx)}
{@const showHeader = shouldShowHeader(idx) || msg.reply_to}
{@const showDate = shouldShowDate(idx)}
{@const replyMsg = resolveReplyMessage(msg.reply_to)}
{#if showDate}
<div class="flex items-center gap-3 py-3 my-2">
@@ -417,38 +497,123 @@
<div
class="group relative {showHeader
? 'mt-5'
: 'mt-0.5'} rounded-lg hover:bg-white/[0.02] px-2 py-0.5 -mx-2 transition-colors"
? 'mt-3'
: 'mt-0.5'} rounded-lg hover:bg-neutral-800/40 px-3 py-1 -mx-3 transition-colors flex items-start self-stretch"
>
{#if showHeader}
<div class="flex items-center gap-2.5 mb-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-md"
style="background-color: {getAvatarColor(msg.user_name)}"
>
{msg.user_name.charAt(0).toUpperCase()}
<!-- Left section: Content -->
<div class="flex-1 min-w-0 pr-8">
{#if replyMsg}
<div class="flex items-center gap-2 mb-1 ml-[34px]">
<div class="w-6 h-px bg-neutral-700"></div>
<Icon
icon="lucide:corner-down-right"
class="w-3 h-3 text-neutral-500 shrink-0"
/>
<img
src={team?.avatar_url || ""}
alt=""
class="w-3 h-3 rounded-full hidden"
/>
<span
class="text-[11px] text-neutral-500 font-medium truncate max-w-[200px]"
>
{replyMsg.user_name}
</span>
<span
class="text-[11px] text-neutral-600 truncate max-w-full italic"
>
{replyMsg.deleted
? "Deleted message"
: replyMsg.content.slice(0, 60)}
</span>
</div>
<span
class="text-[13px] font-semibold {isMe
? 'text-blue-300'
: 'text-neutral-100'}">{isMe ? "You" : msg.user_name}</span
>
<span class="text-[11px] text-neutral-600"
>{formatTime(msg.created_at)}</span
{/if}
{#if showHeader}
<div class="flex items-center gap-2.5 mb-1">
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold text-white shrink-0 shadow-md"
style="background-color: {getAvatarColor(msg.user_name)}"
>
{msg.user_name.charAt(0).toUpperCase()}
</div>
<span
class="text-[13px] font-semibold {isMe
? 'text-blue-300'
: 'text-neutral-200'}"
>
{isMe ? "You" : msg.user_name}
</span>
<span class="text-[11px] text-neutral-500">
{formatTime(msg.created_at)}
</span>
</div>
{/if}
<div class={showHeader ? "pl-[38px]" : "pl-[38px]"}>
{#if msg.deleted}
<p
class="text-[13px] text-neutral-500 italic flex items-center gap-1.5"
>
<Icon icon="lucide:ban" class="w-3.5 h-3.5" />
This message was deleted
</p>
{:else}
<p
class="text-[13.5px] text-neutral-300 leading-normal whitespace-pre-wrap wrap-break-word"
>
{msg.content}
{#if msg.edited_at}
<span
class="text-[10px] text-neutral-500 italic ml-1 select-none"
>(edited)</span
>
{/if}
</p>
{/if}
</div>
</div>
{#if !showHeader && !msg.deleted}
<div
class="absolute left-2 top-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<span class="text-[10px] text-neutral-600"
>{formatTime(msg.created_at).split(" ")[0]}</span
>
</div>
{/if}
<div class={showHeader ? "pl-[42px]" : "pl-[42px]"}>
<p
class="text-[13.5px] text-neutral-300 leading-[1.55] whitespace-pre-wrap wrap-break-word"
>
{msg.content}
</p>
</div>
<span
class="absolute right-2 top-1 text-[10px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity"
>{formatTime(msg.created_at)}</span
<!-- Right section: Hover Actions -->
<div
class="absolute right-3 top-[-10px] opacity-0 group-hover:opacity-100 transition-opacity flex items-center bg-neutral-800 border border-neutral-700 shadow-md rounded-md overflow-hidden z-10"
>
{#if !msg.deleted}
<button
onclick={() => startReply(msg)}
class="p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Reply"
>
<Icon icon="lucide:reply" class="w-3.5 h-3.5" />
</button>
{#if isMe}
<button
onclick={() => startEdit(msg)}
class="p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Edit"
>
<Icon icon="lucide:pencil" class="w-3.5 h-3.5" />
</button>
<button
onclick={() => deleteMessage(msg.id)}
class="p-1.5 text-neutral-400 hover:text-red-400 hover:bg-neutral-700/50 transition-colors border-l border-neutral-700"
title="Delete"
>
<Icon icon="lucide:trash-2" class="w-3.5 h-3.5" />
</button>
{/if}
{/if}
</div>
</div>
{/each}
{/if}
@@ -500,13 +665,49 @@
class="shrink-0 border-t border-neutral-700/60 bg-neutral-800/40 backdrop-blur-sm px-5 py-3"
>
<div class="max-w-4xl mx-auto">
{#if replyingTo || editingMessage}
<div class="mb-2 flex items-center justify-between text-xs px-1">
<div
class="flex items-center gap-2 text-neutral-400 overflow-hidden text-ellipsis whitespace-nowrap"
>
<Icon
icon={editingMessage ? "lucide:pencil" : "lucide:reply"}
class="w-3.5 h-3.5 text-blue-400 shrink-0"
/>
<span class="font-medium text-blue-300">
{editingMessage
? "Editing message"
: `Replying to ${replyingTo?.user_name}`}
</span>
<span
class="text-neutral-500 overflow-hidden text-ellipsis whitespace-nowrap"
>
{(editingMessage?.content || replyingTo?.content || "").slice(
0,
100,
)}
</span>
</div>
<button
onclick={cancelAction}
class="shrink-0 p-1 hover:text-white transition-colors"
title="Cancel"
>
<Icon icon="lucide:x" class="w-3.5 h-3.5" />
</button>
</div>
{/if}
<div class="flex items-end gap-2.5">
<div class="flex-1 relative">
<textarea
bind:this={inputEl}
bind:value={newMessage}
onkeydown={handleKeydown}
placeholder={wsConnected ? "Type a message…" : "Connecting…"}
placeholder={wsConnected
? editingMessage
? "Edit message..."
: "Type a message…"
: "Connecting…"}
rows="1"
class="w-full resize-none bg-neutral-800 border border-neutral-600/80 rounded-xl px-4 py-2.5 text-sm text-white placeholder-neutral-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all max-h-32"
disabled={!wsConnected}
@@ -519,9 +720,12 @@
wsConnected
? 'bg-blue-600 text-white hover:bg-blue-500 shadow-md shadow-blue-600/20 hover:shadow-blue-500/30 active:scale-95'
: 'bg-neutral-800 text-neutral-600 border border-neutral-700 cursor-not-allowed'}"
title="Send (Enter)"
title={editingMessage ? "Save Edit (Enter)" : "Send (Enter)"}
>
<Icon icon="lucide:send" class="w-5 h-5" />
<Icon
icon={editingMessage ? "lucide:check" : "lucide:send"}
class="w-5 h-5"
/>
</button>
</div>
</div>

View File

@@ -76,13 +76,16 @@
/>
</svelte:head>
<div class="flex flex-col -m-6 p-6 overflow-hidden h-full">
<div class="flex flex-col md:-m-6 md:p-6 overflow-hidden h-full">
<div
class="flex flex-1 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-sm h-full"
class="flex flex-col md:flex-row flex-1 overflow-hidden md:rounded-lg md:border border-neutral-700 bg-neutral-800 shadow-sm h-full"
>
<!-- Sidebar List -->
<div
class="w-80 border-r border-neutral-700 flex flex-col shrink-0 bg-neutral-850"
class="w-full md:w-80 md:border-r border-b md:border-b-0 border-neutral-700 flex flex-col shrink-0 bg-neutral-850 {activeDoc &&
!isEditing
? 'hidden md:flex'
: 'flex'}"
>
<div
class="p-4 border-b border-neutral-700 flex items-center justify-between"
@@ -137,7 +140,12 @@
</div>
<!-- Main Content Area -->
<div class="flex-1 flex flex-col min-w-0 bg-neutral-900 overflow-hidden">
<div
class="flex-1 flex flex-col min-w-0 bg-neutral-900 overflow-hidden {!activeDoc ||
(!isEditing && !activeDoc)
? 'hidden md:flex'
: 'flex'}"
>
{#if activeDoc}
<div
class="flex items-center justify-between px-8 py-4 border-b border-neutral-700 bg-neutral-850 shrink-0"
@@ -147,13 +155,30 @@
<input
type="text"
bind:value={editTitle}
class="block w-full px-3 py-1.5 border border-neutral-600 rounded-md bg-neutral-800 text-white text-lg font-semibold focus:ring-blue-500 focus:border-blue-500"
class="text-2xl font-bold bg-transparent border-b border-neutral-600 focus:border-blue-500 focus:outline-none w-full text-white pb-1"
placeholder="Document title"
/>
{:else}
<h1 class="text-xl font-bold text-white truncate">
{activeDoc.title}
</h1>
<p class="text-xs text-neutral-500 mt-0.5">
<div class="flex items-center gap-3">
<button
class="md:hidden text-neutral-400 hover:text-white mr-1"
onclick={() => {
activeDoc = null;
isEditing = false;
}}
title="Back to list"
>
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
</button>
<h2
class="text-2xl font-bold text-white truncate wrap-break-word"
>
{activeDoc.title}
</h2>
</div>
<p
class="text-sm text-neutral-500 mt-1.5 flex items-center gap-2"
>
Last updated {new Date(activeDoc.updated_at).toLocaleDateString(
"en-US",
{ month: "2-digit", day: "2-digit", year: "numeric" },

View File

@@ -108,6 +108,10 @@
let viewingFile = $state<FileItem | null>(null);
let fileInput: HTMLInputElement;
function focusNode(node: HTMLElement) {
node.focus();
}
</script>
<svelte:head>
@@ -125,6 +129,7 @@
<div class="flex items-center space-x-4">
<a
href="/team/{teamId}"
aria-label="Back to team page"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg
@@ -215,7 +220,7 @@
bind:value={folderName}
placeholder="Folder name"
required
autofocus
use:focusNode
class="flex-1 px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<button

View File

@@ -137,10 +137,13 @@
<form onsubmit={saveGeneral} class="p-6 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
<label
for="team-name"
class="block text-sm font-medium text-neutral-300 mb-1"
>Team Name</label
>
<input
id="team-name"
type="text"
bind:value={teamName}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
@@ -186,12 +189,16 @@
{/if}
</div>
<div class="flex flex-col gap-3">
<label class="block text-sm font-medium text-neutral-300">
<label
for="team-avatar"
class="block text-sm font-medium text-neutral-300"
>
Image file <span class="text-neutral-500 font-normal"
>(jpg, png, gif, webp)</span
>
</label>
<input
id="team-avatar"
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
onchange={handleAvatarChange}
@@ -234,12 +241,16 @@
{/if}
</div>
<div class="flex flex-col gap-3">
<label class="block text-sm font-medium text-neutral-300">
<label
for="team-banner"
class="block text-sm font-medium text-neutral-300"
>
Image file <span class="text-neutral-500 font-normal"
>(jpg, png, gif, webp)</span
>
</label>
<input
id="team-banner"
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
onchange={handleBannerChange}

View File

@@ -7,9 +7,16 @@
import { authStore } from "$lib/stores/auth.svelte";
let boardId = $derived($page.params.id ?? "");
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D | null = null;
let canvasContainer: HTMLDivElement;
let canvas: HTMLCanvasElement = $state() as any;
let ctx: CanvasRenderingContext2D | null = $state(null) as any;
let canvasContainer: HTMLDivElement = $state() as any;
let textInputRef: HTMLInputElement | null = $state(null);
$effect(() => {
if (showTextInput && textInputRef) {
textInputRef.focus();
}
});
type DrawObject =
| {
@@ -385,7 +392,9 @@
}
onMount(() => {
ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx = canvas?.getContext("2d", {
willReadFrequently: true,
}) as CanvasRenderingContext2D | null;
if (!ctx) return;
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
@@ -1072,6 +1081,7 @@
onmouseup={endPosition}
onmousemove={draw}
onmouseout={endPosition}
onblur={endPosition}
ondblclick={handleDblClick}
class="absolute inset-0 w-full h-full touch-none block {currentTool ===
'text'
@@ -1090,10 +1100,10 @@
{@const scaleY = canvas ? canvas.height / (rect.height || 1) : 1}
<input
type="text"
bind:this={textInputRef}
bind:value={textInputValue}
onkeydown={handleTextKeydown}
onblur={commitText}
autofocus
style="position:absolute; left:{textX / scaleX}px; top:{(textY -
fontSize) /
scaleY}px; font-size:{fontSize}px; font-family:sans-serif; color:{strokeColor}; background:transparent; border:1px dashed #555; outline:none; min-width:100px; padding:2px 4px; z-index:30;"

View File

@@ -94,7 +94,9 @@
</div>
<div class="text-sm">
<a href="#" class="font-medium text-blue-500 hover:text-blue-400"
<a
href="/forgot-password"
class="font-medium text-blue-500 hover:text-blue-400"
>Forgot your password?</a
>
</div>