Code Warning Fixes
This commit is contained in:
@@ -124,6 +124,10 @@ func main() {
|
|||||||
auth.Post("/refresh", handlers.RefreshToken)
|
auth.Post("/refresh", handlers.RefreshToken)
|
||||||
auth.Post("/logout", middleware.Protected(), handlers.Logout)
|
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 := api.Group("/users", middleware.Protected())
|
||||||
users.Get("/me", handlers.GetMe)
|
users.Get("/me", handlers.GetMe)
|
||||||
users.Put("/me", handlers.UpdateMe)
|
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 {
|
var incoming struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(msg, &incoming) != nil {
|
if json.Unmarshal(msg, &incoming) != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
teamOID, err := primitive.ObjectIDFromHex(teamID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userOID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if incoming.Type == "message" {
|
if incoming.Type == "message" {
|
||||||
content := strings.TrimSpace(incoming.Content)
|
content := strings.TrimSpace(incoming.Content)
|
||||||
if content == "" || len(content) > 5000 {
|
if content == "" || len(content) > 5000 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
teamOID, err := primitive.ObjectIDFromHex(teamID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
userOID, err := primitive.ObjectIDFromHex(userID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
chatMsg := models.ChatMessage{
|
chatMsg := models.ChatMessage{
|
||||||
ID: primitive.NewObjectID(),
|
ID: primitive.NewObjectID(),
|
||||||
TeamID: teamOID,
|
TeamID: teamOID,
|
||||||
@@ -215,6 +217,12 @@ func TeamChatWS(c *websocket.Conn) {
|
|||||||
CreatedAt: time.Now(),
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
_, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg)
|
_, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -224,6 +232,56 @@ func TeamChatWS(c *websocket.Conn) {
|
|||||||
"message": chatMsg,
|
"message": chatMsg,
|
||||||
})
|
})
|
||||||
room.broadcastAll(outMsg)
|
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" {
|
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"})
|
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"
|
field := imageType + "_url"
|
||||||
|
|
||||||
col := database.GetCollection("teams")
|
col := database.GetCollection("teams")
|
||||||
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{
|
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{
|
||||||
field: imageURL,
|
field: imageURL,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"})
|
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 {
|
func ServeTeamBanner(c *fiber.Ctx) error {
|
||||||
return serveTeamImage(c, "banner")
|
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")
|
col := database.GetCollection("users")
|
||||||
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{
|
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(),
|
"updated_at": time.Now(),
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"})
|
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"})
|
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 {
|
func ChangePassword(c *fiber.Ctx) error {
|
||||||
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
|
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -73,20 +73,22 @@ type Subtask struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Card struct {
|
type Card struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"`
|
ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"`
|
||||||
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
|
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
|
||||||
Title string `bson:"title" json:"title"`
|
Title string `bson:"title" json:"title"`
|
||||||
Description string `bson:"description" json:"description"`
|
Description string `bson:"description" json:"description"`
|
||||||
Priority string `bson:"priority" json:"priority"`
|
Priority string `bson:"priority" json:"priority"`
|
||||||
Color string `bson:"color" json:"color"`
|
Color string `bson:"color" json:"color"`
|
||||||
DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
|
DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
|
||||||
Assignees []string `bson:"assignees" json:"assignees"`
|
Assignees []string `bson:"assignees" json:"assignees"`
|
||||||
Subtasks []Subtask `bson:"subtasks" json:"subtasks"`
|
EstimatedMinutes *int `bson:"estimated_minutes,omitempty" json:"estimated_minutes,omitempty"`
|
||||||
Position int `bson:"position" json:"position"`
|
ActualMinutes *int `bson:"actual_minutes,omitempty" json:"actual_minutes,omitempty"`
|
||||||
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
|
Subtasks []Subtask `bson:"subtasks" json:"subtasks"`
|
||||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
Position int `bson:"position" json:"position"`
|
||||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
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 {
|
type Event struct {
|
||||||
@@ -175,10 +177,13 @@ type APIKey struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
|
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
|
||||||
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
|
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
|
||||||
UserName string `bson:"user_name" json:"user_name"`
|
UserName string `bson:"user_name" json:"user_name"`
|
||||||
Content string `bson:"content" json:"content"`
|
Content string `bson:"content" json:"content"`
|
||||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
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: (
|
createCard: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
columnId: 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`, {
|
apiFetch<Card>(`/projects/${projectId}/columns/${columnId}/cards`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -287,7 +287,7 @@ export const board = {
|
|||||||
export const cards = {
|
export const cards = {
|
||||||
update: (
|
update: (
|
||||||
cardId: string,
|
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) }),
|
) => apiFetch<Card>(`/cards/${cardId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
move: (cardId: string, column_id: string, position: number) =>
|
move: (cardId: string, column_id: string, position: number) =>
|
||||||
|
|||||||
@@ -1,70 +1,131 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Markdown from '$lib/components/Markdown/Markdown.svelte';
|
import Markdown from "$lib/components/Markdown/Markdown.svelte";
|
||||||
import type { FileItem } from '$lib/types/api';
|
import type { FileItem } from "$lib/types/api";
|
||||||
import { getAccessToken } from '$lib/api/client';
|
import { getAccessToken } from "$lib/api/client";
|
||||||
import { files as filesApi } from '$lib/api';
|
import { files as filesApi } from "$lib/api";
|
||||||
|
|
||||||
let { file = $bindable<FileItem | null>(null), downloadUrl }: {
|
let {
|
||||||
|
file = $bindable<FileItem | null>(null),
|
||||||
|
downloadUrl,
|
||||||
|
}: {
|
||||||
file: FileItem | null;
|
file: FileItem | null;
|
||||||
downloadUrl: (id: string) => string;
|
downloadUrl: (id: string) => string;
|
||||||
} = $props();
|
} = $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 IMAGE_EXTS = new Set([
|
||||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'ogv', 'mov']);
|
"png",
|
||||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']);
|
"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([
|
const TEXT_EXTS = new Set([
|
||||||
'txt', 'json', 'csv', 'xml', 'yaml', 'yml', 'toml', 'ini', 'env',
|
"txt",
|
||||||
'sh', 'bash', 'zsh', 'fish',
|
"json",
|
||||||
'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs',
|
"csv",
|
||||||
'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php',
|
"xml",
|
||||||
'html', 'css', 'scss', 'less', 'svelte', 'vue',
|
"yaml",
|
||||||
'sql', 'graphql', 'proto', 'dockerfile', 'makefile',
|
"yml",
|
||||||
'log', 'gitignore', 'gitattributes', 'editorconfig',
|
"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 {
|
function ext(filename: string): string {
|
||||||
const parts = filename.toLowerCase().split('.');
|
const parts = filename.toLowerCase().split(".");
|
||||||
return parts.length > 1 ? parts[parts.length - 1] : '';
|
return parts.length > 1 ? parts[parts.length - 1] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewerType(f: FileItem): ViewerType {
|
function viewerType(f: FileItem): ViewerType {
|
||||||
const e = ext(f.name);
|
const e = ext(f.name);
|
||||||
if (e === 'pdf') return 'pdf';
|
if (e === "pdf") return "pdf";
|
||||||
if (IMAGE_EXTS.has(e)) return 'image';
|
if (IMAGE_EXTS.has(e)) return "image";
|
||||||
if (VIDEO_EXTS.has(e)) return 'video';
|
if (VIDEO_EXTS.has(e)) return "video";
|
||||||
if (AUDIO_EXTS.has(e)) return 'audio';
|
if (AUDIO_EXTS.has(e)) return "audio";
|
||||||
if (e === 'md' || e === 'mdx') return 'markdown';
|
if (e === "md" || e === "mdx") return "markdown";
|
||||||
if (TEXT_EXTS.has(e)) return 'text';
|
if (TEXT_EXTS.has(e)) return "text";
|
||||||
return 'none';
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function authFetch(url: string): Promise<Response> {
|
function authFetch(url: string): Promise<Response> {
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
return fetch(url, { headers });
|
return fetch(url, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
let textContent = $state('');
|
let textContent = $state("");
|
||||||
let textLoading = $state(false);
|
let textLoading = $state(false);
|
||||||
let textError = $state('');
|
let textError = $state("");
|
||||||
|
|
||||||
let blobUrl = $state('');
|
let blobUrl = $state("");
|
||||||
let blobLoading = $state(false);
|
let blobLoading = $state(false);
|
||||||
let blobError = $state('');
|
let blobError = $state("");
|
||||||
|
|
||||||
let activeType = $derived(file ? viewerType(file) : 'none');
|
let activeType = $derived(file ? viewerType(file) : "none");
|
||||||
let rawUrl = $derived(file ? downloadUrl(file.id) : '');
|
let rawUrl = $derived(file ? downloadUrl(file.id) : "");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const needsBlob = activeType === 'pdf' || activeType === 'image' || activeType === 'video' || activeType === 'audio';
|
const needsBlob =
|
||||||
|
activeType === "pdf" ||
|
||||||
|
activeType === "image" ||
|
||||||
|
activeType === "video" ||
|
||||||
|
activeType === "audio";
|
||||||
if (blobUrl) {
|
if (blobUrl) {
|
||||||
URL.revokeObjectURL(blobUrl);
|
URL.revokeObjectURL(blobUrl);
|
||||||
blobUrl = '';
|
blobUrl = "";
|
||||||
}
|
}
|
||||||
blobError = '';
|
blobError = "";
|
||||||
if (!file || !needsBlob) return;
|
if (!file || !needsBlob) return;
|
||||||
blobLoading = true;
|
blobLoading = true;
|
||||||
authFetch(rawUrl)
|
authFetch(rawUrl)
|
||||||
@@ -72,9 +133,15 @@
|
|||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
return r.blob();
|
return r.blob();
|
||||||
})
|
})
|
||||||
.then((b) => { blobUrl = URL.createObjectURL(b); })
|
.then((b) => {
|
||||||
.catch((e) => { blobError = e.message; })
|
blobUrl = URL.createObjectURL(b);
|
||||||
.finally(() => { blobLoading = false; });
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
blobError = e.message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
blobLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||||
@@ -82,18 +149,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
textContent = '';
|
textContent = "";
|
||||||
textError = '';
|
textError = "";
|
||||||
if (!file || (activeType !== 'text' && activeType !== 'markdown')) return;
|
if (!file || (activeType !== "text" && activeType !== "markdown")) return;
|
||||||
textLoading = true;
|
textLoading = true;
|
||||||
authFetch(rawUrl)
|
authFetch(rawUrl)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
return r.text();
|
return r.text();
|
||||||
})
|
})
|
||||||
.then((t) => { textContent = t; })
|
.then((t) => {
|
||||||
.catch((e) => { textError = e.message; })
|
textContent = t;
|
||||||
.finally(() => { textLoading = false; });
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
textError = e.message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
textLoading = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
@@ -101,11 +174,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') close();
|
if (e.key === "Escape") close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (!bytes) return '';
|
if (!bytes) return "";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
@@ -115,48 +188,76 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if file}
|
{#if file}
|
||||||
<div class="fixed inset-0 z-50 flex flex-col bg-neutral-950/95 backdrop-blur-sm">
|
<div
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-neutral-700 shrink-0 bg-neutral-900">
|
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="flex items-center gap-3 min-w-0">
|
||||||
<div class="text-sm font-medium text-white truncate">{file.name}</div>
|
<div class="text-sm font-medium text-white truncate">{file.name}</div>
|
||||||
{#if file.size_bytes}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0 ml-4">
|
<div class="flex items-center gap-2 shrink-0 ml-4">
|
||||||
<button
|
<button
|
||||||
onclick={() => filesApi.download(file.id, file.name)}
|
onclick={() => {
|
||||||
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"
|
if (file) filesApi.download(file.id, 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>
|
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"
|
||||||
Download
|
>
|
||||||
</button>
|
<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
|
<button
|
||||||
onclick={close}
|
onclick={close}
|
||||||
class="text-neutral-400 hover:text-white p-1.5 rounded hover:bg-neutral-700 transition-colors"
|
class="text-neutral-400 hover:text-white p-1.5 rounded hover:bg-neutral-700 transition-colors"
|
||||||
title="Close (Esc)"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden flex items-center justify-center">
|
<div class="flex-1 overflow-hidden flex items-center justify-center">
|
||||||
{#if activeType === 'pdf'}
|
{#if activeType === "pdf"}
|
||||||
{#if blobLoading}
|
{#if blobLoading}
|
||||||
<div class="text-neutral-400">Loading…</div>
|
<div class="text-neutral-400">Loading…</div>
|
||||||
{:else if blobError}
|
{:else if blobError}
|
||||||
<div class="text-red-400">Failed to load: {blobError}</div>
|
<div class="text-red-400">Failed to load: {blobError}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<iframe
|
<iframe src={blobUrl} title={file.name} class="w-full h-full border-0"
|
||||||
src={blobUrl}
|
|
||||||
title={file.name}
|
|
||||||
class="w-full h-full border-0"
|
|
||||||
></iframe>
|
></iframe>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if activeType === "image"}
|
||||||
{:else if activeType === 'image'}
|
<div
|
||||||
<div class="w-full h-full overflow-auto flex items-center justify-center p-4">
|
class="w-full h-full overflow-auto flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
{#if blobLoading}
|
{#if blobLoading}
|
||||||
<div class="text-neutral-400">Loading…</div>
|
<div class="text-neutral-400">Loading…</div>
|
||||||
{:else if blobError}
|
{:else if blobError}
|
||||||
@@ -169,8 +270,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeType === "video"}
|
||||||
{:else if activeType === 'video'}
|
|
||||||
<div class="w-full h-full flex items-center justify-center p-4">
|
<div class="w-full h-full flex items-center justify-center p-4">
|
||||||
{#if blobLoading}
|
{#if blobLoading}
|
||||||
<div class="text-neutral-400">Loading…</div>
|
<div class="text-neutral-400">Loading…</div>
|
||||||
@@ -185,56 +285,102 @@
|
|||||||
></video>
|
></video>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeType === "audio"}
|
||||||
{:else if activeType === 'audio'}
|
|
||||||
<div class="flex flex-col items-center justify-center gap-6 p-8">
|
<div class="flex flex-col items-center justify-center gap-6 p-8">
|
||||||
{#if blobLoading}
|
{#if blobLoading}
|
||||||
<div class="text-neutral-400">Loading…</div>
|
<div class="text-neutral-400">Loading…</div>
|
||||||
{:else if blobError}
|
{:else if blobError}
|
||||||
<div class="text-red-400">Failed to load: {blobError}</div>
|
<div class="text-red-400">Failed to load: {blobError}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-24 h-24 rounded-full bg-neutral-800 border border-neutral-600 flex items-center justify-center">
|
<div
|
||||||
<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>
|
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>
|
||||||
<div class="text-neutral-300 text-sm font-medium">{file.name}</div>
|
<div class="text-neutral-300 text-sm font-medium">{file.name}</div>
|
||||||
<audio src={blobUrl} controls class="w-80 max-w-full"></audio>
|
<audio src={blobUrl} controls class="w-80 max-w-full"></audio>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeType === "markdown"}
|
||||||
{:else if activeType === 'markdown'}
|
|
||||||
<div class="w-full h-full overflow-auto">
|
<div class="w-full h-full overflow-auto">
|
||||||
{#if textLoading}
|
{#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}
|
{: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}
|
{:else}
|
||||||
<div class="max-w-3xl mx-auto px-8 py-8">
|
<div class="max-w-3xl mx-auto px-8 py-8">
|
||||||
<Markdown content={textContent} />
|
<Markdown content={textContent} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeType === "text"}
|
||||||
{:else if activeType === 'text'}
|
|
||||||
<div class="w-full h-full overflow-auto">
|
<div class="w-full h-full overflow-auto">
|
||||||
{#if textLoading}
|
{#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}
|
{: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}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center justify-center gap-4 text-neutral-400">
|
<div
|
||||||
<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>
|
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>
|
<p class="text-sm">No preview available for this file type.</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => file && filesApi.download(file.id, file.name)}
|
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"
|
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>
|
<svg
|
||||||
Download {file.name}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script lang="ts">
|
<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() {
|
function close() {
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
@@ -8,16 +14,37 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#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_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="fixed inset-0" onclick={close}></div>
|
<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
|
||||||
<div class="flex items-center justify-between p-4 border-b border-neutral-700 shrink-0">
|
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>
|
<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">
|
<button
|
||||||
<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>
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,10 @@ export interface Card {
|
|||||||
description: string;
|
description: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
color: string;
|
color: string;
|
||||||
due_date: string;
|
due_date?: string;
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
|
estimated_minutes?: number;
|
||||||
|
actual_minutes?: number;
|
||||||
subtasks: Subtask[];
|
subtasks: Subtask[];
|
||||||
position: number;
|
position: number;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
@@ -150,6 +152,7 @@ export interface Webhook {
|
|||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
active?: boolean;
|
||||||
last_triggered?: string;
|
last_triggered?: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -189,5 +192,8 @@ export interface ChatMessage {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
reply_to?: string;
|
||||||
|
edited_at?: string;
|
||||||
|
deleted?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
authStore.user?.name?.charAt(0).toUpperCase() ?? "U",
|
authStore.user?.name?.charAt(0).toUpperCase() ?? "U",
|
||||||
);
|
);
|
||||||
let unreadCount = $state(0);
|
let unreadCount = $state(0);
|
||||||
|
let isMobileMenuOpen = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await authStore.init();
|
await authStore.init();
|
||||||
@@ -162,8 +163,9 @@
|
|||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<button
|
<button
|
||||||
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||||
class="md:hidden text-neutral-400 hover:text-white transition-colors p-2"
|
class="md:hidden text-neutral-400 hover:text-white transition-colors p-2"
|
||||||
aria-label="Open menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
@@ -213,6 +215,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 Content -->
|
||||||
<main class="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
<main class="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -182,10 +182,13 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={() => (showNewTeam = false)}
|
onclick={() => (showNewTeam = false)}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && (showNewTeam = false)}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Create Team dialog"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl w-full max-w-md mx-4"
|
class="bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl w-full max-w-md mx-4"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-4 border-b border-neutral-700"
|
class="flex items-center justify-between p-4 border-b border-neutral-700"
|
||||||
|
|||||||
@@ -139,6 +139,7 @@
|
|||||||
|
|
||||||
let isModalOpen = $state(false);
|
let isModalOpen = $state(false);
|
||||||
let activeColumnIdForNewTask = $state<string | null>(null);
|
let activeColumnIdForNewTask = $state<string | null>(null);
|
||||||
|
let originalColumnIdForTask = $state<string | null>(null);
|
||||||
let editingCardId = $state<string | null>(null);
|
let editingCardId = $state<string | null>(null);
|
||||||
|
|
||||||
let newTask = $state({
|
let newTask = $state({
|
||||||
@@ -148,6 +149,8 @@
|
|||||||
color: "neutral",
|
color: "neutral",
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
assignees: [] as string[],
|
assignees: [] as string[],
|
||||||
|
estimatedMinutes: "",
|
||||||
|
actualMinutes: "",
|
||||||
subtasks: [] as { id: number; text: string; done: boolean }[],
|
subtasks: [] as { id: number; text: string; done: boolean }[],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +203,7 @@
|
|||||||
|
|
||||||
function openCreateTaskModal(columnId: string) {
|
function openCreateTaskModal(columnId: string) {
|
||||||
activeColumnIdForNewTask = columnId;
|
activeColumnIdForNewTask = columnId;
|
||||||
|
originalColumnIdForTask = columnId;
|
||||||
editingCardId = null;
|
editingCardId = null;
|
||||||
newTask = {
|
newTask = {
|
||||||
title: "",
|
title: "",
|
||||||
@@ -208,6 +212,8 @@
|
|||||||
color: "neutral",
|
color: "neutral",
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
assignees: [],
|
assignees: [],
|
||||||
|
estimatedMinutes: "",
|
||||||
|
actualMinutes: "",
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
};
|
};
|
||||||
assigneeInput = "";
|
assigneeInput = "";
|
||||||
@@ -217,6 +223,7 @@
|
|||||||
|
|
||||||
function openEditTaskModal(columnId: string, card: LocalCard) {
|
function openEditTaskModal(columnId: string, card: LocalCard) {
|
||||||
activeColumnIdForNewTask = columnId;
|
activeColumnIdForNewTask = columnId;
|
||||||
|
originalColumnIdForTask = columnId;
|
||||||
editingCardId = card.id;
|
editingCardId = card.id;
|
||||||
newTask = {
|
newTask = {
|
||||||
title: card.title,
|
title: card.title,
|
||||||
@@ -225,6 +232,8 @@
|
|||||||
color: card.color || "neutral",
|
color: card.color || "neutral",
|
||||||
dueDate: card.due_date ? card.due_date.split("T")[0] : "",
|
dueDate: card.due_date ? card.due_date.split("T")[0] : "",
|
||||||
assignees: [...(card.assignees || [])],
|
assignees: [...(card.assignees || [])],
|
||||||
|
estimatedMinutes: card.estimated_minutes?.toString() || "",
|
||||||
|
actualMinutes: card.actual_minutes?.toString() || "",
|
||||||
subtasks: card.subtasks.map((st) => ({ ...st })),
|
subtasks: card.subtasks.map((st) => ({ ...st })),
|
||||||
};
|
};
|
||||||
assigneeInput = "";
|
assigneeInput = "";
|
||||||
@@ -257,15 +266,42 @@
|
|||||||
color: newTask.color,
|
color: newTask.color,
|
||||||
due_date: newTask.dueDate,
|
due_date: newTask.dueDate,
|
||||||
assignees: newTask.assignees,
|
assignees: newTask.assignees,
|
||||||
|
estimated_minutes: newTask.estimatedMinutes
|
||||||
|
? parseInt(newTask.estimatedMinutes)
|
||||||
|
: undefined,
|
||||||
|
actual_minutes: newTask.actualMinutes
|
||||||
|
? parseInt(newTask.actualMinutes)
|
||||||
|
: undefined,
|
||||||
subtasks: newTask.subtasks.map((st) => ({
|
subtasks: newTask.subtasks.map((st) => ({
|
||||||
id: st.id,
|
id: st.id,
|
||||||
text: st.text,
|
text: st.text,
|
||||||
done: st.done,
|
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,
|
...card,
|
||||||
...updated,
|
...updated,
|
||||||
subtasks: (updated.subtasks ?? []).map((st) => ({
|
subtasks: (updated.subtasks ?? []).map((st) => ({
|
||||||
@@ -273,9 +309,18 @@
|
|||||||
text: st.text,
|
text: st.text,
|
||||||
done: st.done,
|
done: st.done,
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
: card,
|
targetCol.cards.push(formattedCard);
|
||||||
);
|
await cardsApi
|
||||||
|
.move(
|
||||||
|
editingCardId,
|
||||||
|
activeColumnIdForNewTask!,
|
||||||
|
targetCol.cards.length - 1,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const created = await boardApi.createCard(
|
const created = await boardApi.createCard(
|
||||||
boardId,
|
boardId,
|
||||||
@@ -287,6 +332,12 @@
|
|||||||
color: newTask.color,
|
color: newTask.color,
|
||||||
due_date: newTask.dueDate || "",
|
due_date: newTask.dueDate || "",
|
||||||
assignees: newTask.assignees,
|
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: [] }];
|
targetCol.cards = [...targetCol.cards, { ...created, subtasks: [] }];
|
||||||
@@ -561,8 +612,8 @@
|
|||||||
ondragstart={(e) => handleDragStart(card.id, column.id, e)}
|
ondragstart={(e) => handleDragStart(card.id, column.id, e)}
|
||||||
class="bg-neutral-750 p-4 rounded-md border border-neutral-600 shadow-sm {isArchived
|
class="bg-neutral-750 p-4 rounded-md border border-neutral-600 shadow-sm {isArchived
|
||||||
? 'cursor-default'
|
? 'cursor-default'
|
||||||
: 'cursor-grab active:cursor-grabbing'} hover:border-neutral-500 transition-colors flex flex-col gap-2 group relative overflow-hidden"
|
: 'cursor-pointer hover:border-neutral-500'} transition-colors flex flex-col gap-2 group relative overflow-hidden"
|
||||||
role="listitem"
|
role="button"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
!isArchived && openEditTaskModal(column.id, card)}
|
!isArchived && openEditTaskModal(column.id, card)}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
@@ -642,6 +693,20 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{#if card.assignees && card.assignees.length > 0}
|
{#if card.assignees && card.assignees.length > 0}
|
||||||
<div class="flex -space-x-1 overflow-hidden">
|
<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">Status</th>
|
||||||
<th class="px-4 py-3 font-medium">Priority</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">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">Assignees</th>
|
||||||
<th class="px-4 py-3 font-medium">Subtasks</th>
|
<th class="px-4 py-3 font-medium">Subtasks</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -751,6 +818,20 @@
|
|||||||
<span class="text-neutral-600">—</span>
|
<span class="text-neutral-600">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3">
|
||||||
{#if card.assignees?.length}
|
{#if card.assignees?.length}
|
||||||
<div class="flex -space-x-1">
|
<div class="flex -space-x-1">
|
||||||
@@ -777,7 +858,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr
|
<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
|
>No tasks yet.</td
|
||||||
></tr
|
></tr
|
||||||
>
|
>
|
||||||
@@ -793,7 +874,7 @@
|
|||||||
? new Date(
|
? new Date(
|
||||||
Math.min(
|
Math.min(
|
||||||
...datedCards.map((c) =>
|
...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,
|
now.getTime() - 7 * 86400000,
|
||||||
),
|
),
|
||||||
@@ -802,7 +883,7 @@
|
|||||||
{@const ganttEnd = datedCards.length
|
{@const ganttEnd = datedCards.length
|
||||||
? new Date(
|
? new Date(
|
||||||
Math.max(
|
Math.max(
|
||||||
...datedCards.map((c) => new Date(c.due_date).getTime()),
|
...datedCards.map((c) => new Date(c.due_date!).getTime()),
|
||||||
now.getTime() + 7 * 86400000,
|
now.getTime() + 7 * 86400000,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -847,8 +928,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each datedCards as card (card.id)}
|
{#each datedCards as card (card.id)}
|
||||||
{@const created = new Date(card.created_at || card.due_date)}
|
{@const created = new Date(card.created_at || card.due_date!)}
|
||||||
{@const due = new Date(card.due_date)}
|
{@const due = new Date(card.due_date!)}
|
||||||
{@const startOffset = Math.max(
|
{@const startOffset = Math.max(
|
||||||
0,
|
0,
|
||||||
((created.getTime() - ganttStart.getTime()) /
|
((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"
|
class="w-56 shrink-0 px-4 py-3 border-r border-neutral-700 flex items-center gap-2 cursor-pointer"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
!isArchived && openEditTaskModal(card.columnId, card)}
|
!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
|
{#if card.color && card.color !== "neutral"}<div
|
||||||
class="w-1 h-5 rounded-full {colorClasses[card.color]}"
|
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"
|
class="bg-neutral-800 border border-neutral-700 rounded-lg p-3 hover:border-neutral-600 transition-colors cursor-pointer group"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
!isArchived && openEditTaskModal(column.id, card)}
|
!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-start justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
@@ -971,7 +1064,7 @@
|
|||||||
{#if card.due_date}
|
{#if card.due_date}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Icon icon="lucide:calendar" class="w-3 h-3" />
|
<Icon icon="lucide:calendar" class="w-3 h-3" />
|
||||||
{new Date(card.due_date).toLocaleDateString(
|
{new Date(card.due_date!).toLocaleDateString(
|
||||||
"en-US",
|
"en-US",
|
||||||
{ month: "short", day: "numeric" },
|
{ month: "short", day: "numeric" },
|
||||||
)}
|
)}
|
||||||
@@ -1024,225 +1117,338 @@
|
|||||||
<Modal
|
<Modal
|
||||||
bind:isOpen={isModalOpen}
|
bind:isOpen={isModalOpen}
|
||||||
title={editingCardId ? "Edit Task" : "Create New Task"}
|
title={editingCardId ? "Edit Task" : "Create New Task"}
|
||||||
|
maxWidth="max-w-5xl"
|
||||||
>
|
>
|
||||||
<div class="space-y-6">
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
|
<!-- Left Main Content Area -->
|
||||||
<div class="md:col-span-8">
|
<div class="flex-1 space-y-6">
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<div>
|
||||||
>Title</label
|
<label
|
||||||
|
for="task-title-input"
|
||||||
|
class="block text-sm font-semibold text-neutral-300 mb-1.5"
|
||||||
|
>Task Title</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="task-title-input"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newTask.title}
|
bind:value={newTask.title}
|
||||||
placeholder="Task title"
|
placeholder="Enter a descriptive 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"
|
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>
|
||||||
<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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<div class="flex items-center justify-between mb-2">
|
||||||
>Due Date</label
|
<label
|
||||||
>
|
for="task-desc-input"
|
||||||
<input
|
class="block text-sm font-semibold text-neutral-300"
|
||||||
type="date"
|
>Description</label
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{#each userSearchResults as user}
|
<div
|
||||||
<button
|
class="flex items-center bg-neutral-800 rounded-md border border-neutral-700 p-0.5"
|
||||||
type="button"
|
>
|
||||||
onclick={() => selectUser(user)}
|
<button
|
||||||
class="w-full flex items-center gap-3 px-3 py-2 hover:bg-neutral-700 transition-colors text-left"
|
class="text-xs font-medium px-3 py-1.5 rounded-md transition-colors {previewMarkdown
|
||||||
>
|
? 'text-neutral-400 hover:text-neutral-300'
|
||||||
<div
|
: 'bg-neutral-700 text-white shadow-sm'}"
|
||||||
class="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center text-xs font-bold text-white uppercase shrink-0"
|
onclick={() => (previewMarkdown = false)}>Write</button
|
||||||
>
|
|
||||||
{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
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="ml-auto text-neutral-500 hover:text-red-400"
|
class="text-xs font-medium px-3 py-1.5 rounded-md transition-colors {previewMarkdown
|
||||||
onclick={() =>
|
? 'bg-neutral-700 text-white shadow-sm'
|
||||||
(newTask.subtasks = newTask.subtasks.filter(
|
: 'text-neutral-400 hover:text-neutral-300'}"
|
||||||
(st) => st.id !== subtask.id,
|
onclick={() => (previewMarkdown = true)}>Preview</button
|
||||||
))}
|
|
||||||
>
|
>
|
||||||
<Icon icon="lucide:x" class="w-4 h-4" />
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</li>
|
<div
|
||||||
{/each}
|
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]"
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
Add
|
{#if previewMarkdown}
|
||||||
</button>
|
<div class="p-5 h-full prose prose-invert max-w-none text-sm">
|
||||||
</form>
|
{#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>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4 border-t border-neutral-700 gap-3">
|
<!-- Right Sidebar: Metadata & Attributes -->
|
||||||
<button
|
<div
|
||||||
onclick={() => (isModalOpen = false)}
|
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"
|
||||||
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"
|
>
|
||||||
>
|
<div class="space-y-4">
|
||||||
Cancel
|
<h3 class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
|
||||||
</button>
|
Properties
|
||||||
<button
|
</h3>
|
||||||
onclick={saveNewTask}
|
|
||||||
disabled={!newTask.title.trim()}
|
<!-- Status (Column) mapping if we wanted to change status - currently tied to activeColumnIdForNewTask -->
|
||||||
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"
|
{#if editingCardId && activeColumnIdForNewTask}
|
||||||
>
|
<div>
|
||||||
{editingCardId ? "Save Changes" : "Create Task"}
|
<label
|
||||||
</button>
|
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>
|
||||||
</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>
|
||||||
|
|
||||||
<Modal bind:isOpen={isShareModalOpen} title="Share & Visibility">
|
<Modal bind:isOpen={isShareModalOpen} title="Share & Visibility">
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
.filter((c) => c.due_date)
|
.filter((c) => c.due_date)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
date: c.due_date.split("T")[0],
|
date: c.due_date!.split("T")[0],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
time: "",
|
time: "",
|
||||||
color: priorityColor[c.priority] ?? "blue",
|
color: priorityColor[c.priority] ?? "blue",
|
||||||
@@ -129,11 +129,8 @@
|
|||||||
<div class="flex items-center justify-between mb-6 shrink-0">
|
<div class="flex items-center justify-between mb-6 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||||
Organization Calendar
|
Personal Calendar
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-neutral-400 mt-1">
|
|
||||||
Overview of all team events and milestones.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<button
|
<button
|
||||||
@@ -156,10 +153,12 @@
|
|||||||
<Modal bind:isOpen={isModalOpen} title="Add Event">
|
<Modal bind:isOpen={isModalOpen} title="Add Event">
|
||||||
<form onsubmit={handleAddEvent} class="space-y-4">
|
<form onsubmit={handleAddEvent} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Title</label
|
for="event-title"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Title</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-title"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newEvent.title}
|
bind:value={newEvent.title}
|
||||||
required
|
required
|
||||||
@@ -170,10 +169,12 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Date</label
|
for="event-date"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Date</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-date"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={newEvent.date}
|
bind:value={newEvent.date}
|
||||||
required
|
required
|
||||||
@@ -181,10 +182,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Time</label
|
for="event-time"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Time</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-time"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newEvent.time}
|
bind:value={newEvent.time}
|
||||||
placeholder="e.g. 10:00 AM"
|
placeholder="e.g. 10:00 AM"
|
||||||
@@ -195,10 +198,12 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Color</label
|
for="event-color"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Color</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
|
id="event-color"
|
||||||
bind:value={newEvent.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"
|
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Team</label
|
for="event-team"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Team</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
|
id="event-team"
|
||||||
bind:value={newEvent.teamId}
|
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"
|
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>
|
||||||
|
|
||||||
<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
|
>Description</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="event-desc"
|
||||||
bind:value={newEvent.description}
|
bind:value={newEvent.description}
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a
|
<a
|
||||||
href="/"
|
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"
|
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
let calendarEvents = $derived(
|
let calendarEvents = $derived(
|
||||||
rawEvents.map((e) => {
|
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 hours = dt.getHours();
|
||||||
const minutes = dt.getMinutes().toString().padStart(2, "0");
|
const minutes = dt.getMinutes().toString().padStart(2, "0");
|
||||||
const ampm = hours >= 12 ? "PM" : "AM";
|
const ampm = hours >= 12 ? "PM" : "AM";
|
||||||
const h = hours % 12 || 12;
|
const h = hours % 12 || 12;
|
||||||
return {
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
date: e.start_time.split("T")[0],
|
date: e.date,
|
||||||
title: e.title,
|
title: e.title,
|
||||||
time: `${h}:${minutes} ${ampm}`,
|
time: `${h}:${minutes} ${ampm}`,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
@@ -50,14 +50,12 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
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, {
|
const created = await projectsApi.createEvent(projectId, {
|
||||||
title: newEvent.title,
|
title: newEvent.title,
|
||||||
description: newEvent.description,
|
description: newEvent.description,
|
||||||
start_time: startTime,
|
date: newEvent.date,
|
||||||
end_time: startTime,
|
time: newEvent.time,
|
||||||
|
color: "blue", // Hardcoded color since no color selector is present in this form yet
|
||||||
});
|
});
|
||||||
rawEvents = [...rawEvents, created];
|
rawEvents = [...rawEvents, created];
|
||||||
isModalOpen = false;
|
isModalOpen = false;
|
||||||
@@ -85,6 +83,7 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a
|
<a
|
||||||
href="/projects"
|
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"
|
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" />
|
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
|
||||||
@@ -148,10 +147,12 @@
|
|||||||
<p class="text-sm text-red-400">{error}</p>
|
<p class="text-sm text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Title</label
|
for="event-title"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Title</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-title"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newEvent.title}
|
bind:value={newEvent.title}
|
||||||
required
|
required
|
||||||
@@ -160,10 +161,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
>Description</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="event-desc"
|
||||||
bind:value={newEvent.description}
|
bind:value={newEvent.description}
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
@@ -172,10 +176,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
>Date</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-date"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={newEvent.date}
|
bind:value={newEvent.date}
|
||||||
required
|
required
|
||||||
@@ -183,10 +190,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
>Time</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-time"
|
||||||
type="time"
|
type="time"
|
||||||
bind:value={newEvent.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"
|
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 viewingFile = $state<FileItem | null>(null);
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
function focusNode(node: HTMLElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -129,6 +133,7 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a
|
<a
|
||||||
href="/projects"
|
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"
|
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -219,7 +224,7 @@
|
|||||||
bind:value={folderName}
|
bind:value={folderName}
|
||||||
placeholder="Folder name"
|
placeholder="Folder name"
|
||||||
required
|
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"
|
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
|
<button
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
<div class="flex items-center space-x-4 mb-2">
|
<div class="flex items-center space-x-4 mb-2">
|
||||||
<a
|
<a
|
||||||
href="/projects"
|
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"
|
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"
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
try {
|
try {
|
||||||
const created = await projectsApi.createWebhook(projectId, {
|
const created = await projectsApi.createWebhook(projectId, {
|
||||||
name: newWebhook.name,
|
name: newWebhook.name,
|
||||||
|
type: "discord",
|
||||||
url: newWebhook.url,
|
url: newWebhook.url,
|
||||||
events: ["*"],
|
|
||||||
});
|
});
|
||||||
webhookList = [...webhookList, created];
|
webhookList = [...webhookList, created];
|
||||||
isModalOpen = false;
|
isModalOpen = false;
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-neutral-500 mt-1 capitalize">
|
<div class="text-xs text-neutral-500 mt-1 capitalize">
|
||||||
{wtype} • Last: {formatLastTriggered(
|
{wtype} • Last: {formatLastTriggered(
|
||||||
webhook.last_triggered,
|
webhook.last_triggered || "",
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,10 +297,13 @@
|
|||||||
|
|
||||||
<form onsubmit={addWebhook} class="p-6 space-y-4">
|
<form onsubmit={addWebhook} class="p-6 space-y-4">
|
||||||
<div>
|
<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
|
>Service Type</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
|
id="webhook-type"
|
||||||
bind:value={newWebhook.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"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Name</label
|
for="webhook-name"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Name</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="webhook-name"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newWebhook.name}
|
bind:value={newWebhook.name}
|
||||||
required
|
required
|
||||||
@@ -326,10 +331,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
>Payload URL</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="webhook-url"
|
||||||
type="url"
|
type="url"
|
||||||
bind:value={newWebhook.url}
|
bind:value={newWebhook.url}
|
||||||
required
|
required
|
||||||
@@ -339,10 +347,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
>Secret Token (Optional)</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="webhook-secret"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={newWebhook.secret}
|
bind:value={newWebhook.secret}
|
||||||
placeholder="Used to sign webhook payloads"
|
placeholder="Used to sign webhook payloads"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
let passwordSuccess = $state("");
|
let passwordSuccess = $state("");
|
||||||
let avatarError = $state("");
|
let avatarError = $state("");
|
||||||
let avatarSuccess = $state("");
|
let avatarSuccess = $state("");
|
||||||
|
let avatarCacheBust = $state("");
|
||||||
|
|
||||||
// --- API Keys state ---
|
// --- API Keys state ---
|
||||||
const ALL_SCOPES = [
|
const ALL_SCOPES = [
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
try {
|
try {
|
||||||
const updated = await usersApi.uploadAvatar(file);
|
const updated = await usersApi.uploadAvatar(file);
|
||||||
authStore.setUser(updated);
|
authStore.setUser(updated);
|
||||||
|
avatarCacheBust = "?t=" + Date.now();
|
||||||
avatarSuccess = "Avatar updated successfully.";
|
avatarSuccess = "Avatar updated successfully.";
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
avatarError =
|
avatarError =
|
||||||
@@ -253,7 +255,7 @@
|
|||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
{#if authStore.user?.avatar_url}
|
{#if authStore.user?.avatar_url}
|
||||||
<img
|
<img
|
||||||
src={authStore.user.avatar_url}
|
src="{authStore.user.avatar_url}{avatarCacheBust}"
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
class="h-16 w-16 rounded-full object-cover shadow-inner"
|
class="h-16 w-16 rounded-full object-cover shadow-inner"
|
||||||
/>
|
/>
|
||||||
@@ -660,7 +662,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each apiKeyList as key (key.id)}
|
{#each apiKeyList as key (key.id)}
|
||||||
<div
|
<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-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-1.5">
|
<div class="flex items-center gap-3 mb-1.5">
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
.filter((c) => c.due_date)
|
.filter((c) => c.due_date)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
date: c.due_date.split("T")[0],
|
date: c.due_date!.split("T")[0],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
projectName: projectData[i].name,
|
projectName: projectData[i].name,
|
||||||
projectId: projectData[i].id,
|
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"
|
class="bg-neutral-800 rounded-xl border border-neutral-700 shadow-sm overflow-hidden shrink-0 relative"
|
||||||
>
|
>
|
||||||
<div
|
<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}
|
{#if team.banner_url}
|
||||||
<img
|
<img
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<a
|
||||||
href="/team/{teamId}/calendar"
|
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"
|
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)
|
.filter((c) => c.due_date)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
date: c.due_date.split("T")[0],
|
date: c.due_date!.split("T")[0],
|
||||||
title: c.title,
|
title: c.title,
|
||||||
time: "",
|
time: "",
|
||||||
color: priorityColor[c.priority] ?? "blue",
|
color: priorityColor[c.priority] ?? "blue",
|
||||||
@@ -128,6 +128,7 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a
|
<a
|
||||||
href="/team/{teamId}"
|
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"
|
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" />
|
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
|
||||||
@@ -187,10 +188,12 @@
|
|||||||
<p class="text-sm text-red-400">{error}</p>
|
<p class="text-sm text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Title</label
|
for="event-title"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Title</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-title"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newEvent.title}
|
bind:value={newEvent.title}
|
||||||
required
|
required
|
||||||
@@ -199,10 +202,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
>Description</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="event-desc"
|
||||||
bind:value={newEvent.description}
|
bind:value={newEvent.description}
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
@@ -211,10 +217,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
>Date</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-date"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={newEvent.date}
|
bind:value={newEvent.date}
|
||||||
required
|
required
|
||||||
@@ -222,10 +231,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
>Time</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="event-time"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newEvent.time}
|
bind:value={newEvent.time}
|
||||||
placeholder="e.g. 10:00 AM"
|
placeholder="e.g. 10:00 AM"
|
||||||
@@ -234,10 +246,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
<label
|
||||||
>Color</label
|
for="event-color"
|
||||||
|
class="block text-sm font-medium text-neutral-300 mb-1">Color</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
|
id="event-color"
|
||||||
bind:value={newEvent.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"
|
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 loadingMore = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
|
let replyingTo = $state<ChatMessage | null>(null);
|
||||||
|
let editingMessage = $state<ChatMessage | null>(null);
|
||||||
|
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let wsConnected = $state(false);
|
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 (
|
if (
|
||||||
type === "typing" &&
|
type === "typing" &&
|
||||||
typeof msg.user_id === "string" &&
|
typeof msg.user_id === "string" &&
|
||||||
@@ -156,14 +180,69 @@
|
|||||||
const content = newMessage.trim();
|
const content = newMessage.trim();
|
||||||
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
sending = true;
|
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 = "";
|
newMessage = "";
|
||||||
sending = false;
|
sending = false;
|
||||||
shouldAutoScroll = true;
|
shouldAutoScroll = true;
|
||||||
requestAnimationFrame(() => inputEl?.focus());
|
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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
cancelAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
sendMessage();
|
||||||
@@ -333,8 +412,8 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-1.5 h-1.5 rounded-full {wsConnected
|
class="w-1.5 h-1.5 rounded-full {wsConnected
|
||||||
? 'bg-emerald-400 shadow-[0_0_6px_theme(colors.emerald.400)]'
|
? 'bg-emerald-400 shadow-[0_0_6px_var(--color-emerald-400)]'
|
||||||
: 'bg-red-400 shadow-[0_0_6px_theme(colors.red.400)]'}"
|
: 'bg-red-400 shadow-[0_0_6px_var(--color-red-400)]'}"
|
||||||
></div>
|
></div>
|
||||||
<span
|
<span
|
||||||
class="text-[10px] font-medium {wsConnected
|
class="text-[10px] font-medium {wsConnected
|
||||||
@@ -368,7 +447,7 @@
|
|||||||
{:else if messages.length === 0}
|
{:else if messages.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center h-64 text-center">
|
<div class="flex flex-col items-center justify-center h-64 text-center">
|
||||||
<div
|
<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" />
|
<Icon icon="lucide:message-circle" class="w-9 h-9 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -401,8 +480,9 @@
|
|||||||
|
|
||||||
{#each messages as msg, idx}
|
{#each messages as msg, idx}
|
||||||
{@const isMe = msg.user_id === myId}
|
{@const isMe = msg.user_id === myId}
|
||||||
{@const showHeader = shouldShowHeader(idx)}
|
{@const showHeader = shouldShowHeader(idx) || msg.reply_to}
|
||||||
{@const showDate = shouldShowDate(idx)}
|
{@const showDate = shouldShowDate(idx)}
|
||||||
|
{@const replyMsg = resolveReplyMessage(msg.reply_to)}
|
||||||
|
|
||||||
{#if showDate}
|
{#if showDate}
|
||||||
<div class="flex items-center gap-3 py-3 my-2">
|
<div class="flex items-center gap-3 py-3 my-2">
|
||||||
@@ -417,38 +497,123 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="group relative {showHeader
|
class="group relative {showHeader
|
||||||
? 'mt-5'
|
? 'mt-3'
|
||||||
: 'mt-0.5'} rounded-lg hover:bg-white/[0.02] px-2 py-0.5 -mx-2 transition-colors"
|
: '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}
|
<!-- Left section: Content -->
|
||||||
<div class="flex items-center gap-2.5 mb-1">
|
<div class="flex-1 min-w-0 pr-8">
|
||||||
<div
|
{#if replyMsg}
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-md"
|
<div class="flex items-center gap-2 mb-1 ml-[34px]">
|
||||||
style="background-color: {getAvatarColor(msg.user_name)}"
|
<div class="w-6 h-px bg-neutral-700"></div>
|
||||||
>
|
<Icon
|
||||||
{msg.user_name.charAt(0).toUpperCase()}
|
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>
|
</div>
|
||||||
<span
|
{/if}
|
||||||
class="text-[13px] font-semibold {isMe
|
|
||||||
? 'text-blue-300'
|
{#if showHeader}
|
||||||
: 'text-neutral-100'}">{isMe ? "You" : msg.user_name}</span
|
<div class="flex items-center gap-2.5 mb-1">
|
||||||
>
|
<div
|
||||||
<span class="text-[11px] text-neutral-600"
|
class="w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold text-white shrink-0 shadow-md"
|
||||||
>{formatTime(msg.created_at)}</span
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class={showHeader ? "pl-[42px]" : "pl-[42px]"}>
|
|
||||||
<p
|
<!-- Right section: Hover Actions -->
|
||||||
class="text-[13.5px] text-neutral-300 leading-[1.55] whitespace-pre-wrap wrap-break-word"
|
<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"
|
||||||
{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
|
|
||||||
>
|
>
|
||||||
|
{#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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/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"
|
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">
|
<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 items-end gap-2.5">
|
||||||
<div class="flex-1 relative">
|
<div class="flex-1 relative">
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={newMessage}
|
bind:value={newMessage}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder={wsConnected ? "Type a message…" : "Connecting…"}
|
placeholder={wsConnected
|
||||||
|
? editingMessage
|
||||||
|
? "Edit message..."
|
||||||
|
: "Type a message…"
|
||||||
|
: "Connecting…"}
|
||||||
rows="1"
|
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"
|
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}
|
disabled={!wsConnected}
|
||||||
@@ -519,9 +720,12 @@
|
|||||||
wsConnected
|
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-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'}"
|
: '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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,13 +76,16 @@
|
|||||||
/>
|
/>
|
||||||
</svelte:head>
|
</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
|
<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 -->
|
<!-- Sidebar List -->
|
||||||
<div
|
<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
|
<div
|
||||||
class="p-4 border-b border-neutral-700 flex items-center justify-between"
|
class="p-4 border-b border-neutral-700 flex items-center justify-between"
|
||||||
@@ -137,7 +140,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- 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}
|
{#if activeDoc}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between px-8 py-4 border-b border-neutral-700 bg-neutral-850 shrink-0"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={editTitle}
|
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}
|
{:else}
|
||||||
<h1 class="text-xl font-bold text-white truncate">
|
<div class="flex items-center gap-3">
|
||||||
{activeDoc.title}
|
<button
|
||||||
</h1>
|
class="md:hidden text-neutral-400 hover:text-white mr-1"
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">
|
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(
|
Last updated {new Date(activeDoc.updated_at).toLocaleDateString(
|
||||||
"en-US",
|
"en-US",
|
||||||
{ month: "2-digit", day: "2-digit", year: "numeric" },
|
{ month: "2-digit", day: "2-digit", year: "numeric" },
|
||||||
|
|||||||
@@ -108,6 +108,10 @@
|
|||||||
let viewingFile = $state<FileItem | null>(null);
|
let viewingFile = $state<FileItem | null>(null);
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
function focusNode(node: HTMLElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -125,6 +129,7 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a
|
<a
|
||||||
href="/team/{teamId}"
|
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"
|
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -215,7 +220,7 @@
|
|||||||
bind:value={folderName}
|
bind:value={folderName}
|
||||||
placeholder="Folder name"
|
placeholder="Folder name"
|
||||||
required
|
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"
|
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
|
<button
|
||||||
|
|||||||
@@ -137,10 +137,13 @@
|
|||||||
<form onsubmit={saveGeneral} class="p-6 space-y-4">
|
<form onsubmit={saveGeneral} class="p-6 space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<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
|
>Team Name</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="team-name"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={teamName}
|
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"
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3">
|
<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"
|
Image file <span class="text-neutral-500 font-normal"
|
||||||
>(jpg, png, gif, webp)</span
|
>(jpg, png, gif, webp)</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="team-avatar"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||||
onchange={handleAvatarChange}
|
onchange={handleAvatarChange}
|
||||||
@@ -234,12 +241,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3">
|
<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"
|
Image file <span class="text-neutral-500 font-normal"
|
||||||
>(jpg, png, gif, webp)</span
|
>(jpg, png, gif, webp)</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="team-banner"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||||
onchange={handleBannerChange}
|
onchange={handleBannerChange}
|
||||||
|
|||||||
@@ -7,9 +7,16 @@
|
|||||||
import { authStore } from "$lib/stores/auth.svelte";
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
let boardId = $derived($page.params.id ?? "");
|
let boardId = $derived($page.params.id ?? "");
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement = $state() as any;
|
||||||
let ctx: CanvasRenderingContext2D | null = null;
|
let ctx: CanvasRenderingContext2D | null = $state(null) as any;
|
||||||
let canvasContainer: HTMLDivElement;
|
let canvasContainer: HTMLDivElement = $state() as any;
|
||||||
|
|
||||||
|
let textInputRef: HTMLInputElement | null = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (showTextInput && textInputRef) {
|
||||||
|
textInputRef.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type DrawObject =
|
type DrawObject =
|
||||||
| {
|
| {
|
||||||
@@ -385,7 +392,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
ctx = canvas?.getContext("2d", {
|
||||||
|
willReadFrequently: true,
|
||||||
|
}) as CanvasRenderingContext2D | null;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
window.addEventListener("resize", resizeCanvas);
|
window.addEventListener("resize", resizeCanvas);
|
||||||
@@ -1072,6 +1081,7 @@
|
|||||||
onmouseup={endPosition}
|
onmouseup={endPosition}
|
||||||
onmousemove={draw}
|
onmousemove={draw}
|
||||||
onmouseout={endPosition}
|
onmouseout={endPosition}
|
||||||
|
onblur={endPosition}
|
||||||
ondblclick={handleDblClick}
|
ondblclick={handleDblClick}
|
||||||
class="absolute inset-0 w-full h-full touch-none block {currentTool ===
|
class="absolute inset-0 w-full h-full touch-none block {currentTool ===
|
||||||
'text'
|
'text'
|
||||||
@@ -1090,10 +1100,10 @@
|
|||||||
{@const scaleY = canvas ? canvas.height / (rect.height || 1) : 1}
|
{@const scaleY = canvas ? canvas.height / (rect.height || 1) : 1}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
bind:this={textInputRef}
|
||||||
bind:value={textInputValue}
|
bind:value={textInputValue}
|
||||||
onkeydown={handleTextKeydown}
|
onkeydown={handleTextKeydown}
|
||||||
onblur={commitText}
|
onblur={commitText}
|
||||||
autofocus
|
|
||||||
style="position:absolute; left:{textX / scaleX}px; top:{(textY -
|
style="position:absolute; left:{textX / scaleX}px; top:{(textY -
|
||||||
fontSize) /
|
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;"
|
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>
|
||||||
|
|
||||||
<div class="text-sm">
|
<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
|
>Forgot your password?</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user