Code Warning Fixes
This commit is contained in:
@@ -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
BIN
server/fpmb_server
Executable file
Binary file not shown.
@@ -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" {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user