feat: enhance frontend with new UI components and Tailwind CSS integration
- Added CreateButton and DocumentCard components for document management. - Implemented tabbed interface for owned and shared documents in Home page. - Integrated Tailwind CSS for styling and layout improvements. - Introduced utility functions for class name management. - Updated package.json with new dependencies for UI components and styling. - Created PostCSS configuration for Tailwind CSS. - Refactored Navbar and button components for better usability and design. - Enhanced document API to include owner_id for document sharing functionality.
This commit is contained in:
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
||||||
@@ -13,6 +14,10 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// connectionSem limits concurrent WebSocket connection handshakes
|
||||||
|
// to prevent overwhelming the database during connection storms
|
||||||
|
var connectionSem = make(chan struct{}, 200)
|
||||||
|
|
||||||
type WebSocketHandler struct {
|
type WebSocketHandler struct {
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
store store.Store
|
store store.Store
|
||||||
@@ -44,6 +49,15 @@ func (wsh *WebSocketHandler) getUpgrader() websocket.Upgrader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
||||||
|
// Acquire semaphore to limit concurrent connection handshakes
|
||||||
|
select {
|
||||||
|
case connectionSem <- struct{}{}:
|
||||||
|
defer func() { <-connectionSem }()
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "server busy, retry later"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
roomID := c.Param("roomId")
|
roomID := c.Param("roomId")
|
||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId is required"})
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ type Hub struct {
|
|||||||
func NewHub(messagebus messagebus.MessageBus, serverID string, logger *zap.Logger) *Hub {
|
func NewHub(messagebus messagebus.MessageBus, serverID string, logger *zap.Logger) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
rooms: make(map[string]*Room),
|
rooms: make(map[string]*Room),
|
||||||
Register: make(chan *Client, 256),
|
Register: make(chan *Client, 2048),
|
||||||
Unregister: make(chan *Client, 256),
|
Unregister: make(chan *Client, 2048),
|
||||||
Broadcast: make(chan *Message, 4096),
|
Broadcast: make(chan *Message, 4096),
|
||||||
// redis
|
// redis
|
||||||
messagebus: messagebus,
|
messagebus: messagebus,
|
||||||
@@ -242,8 +242,8 @@ func (h *Hub) unregisterClient(client *Client) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 协作清理:发送“僵尸删除”消息给其他幸存者
|
// 3. 协作清理:发送"僵尸删除"消息
|
||||||
if remainingClientsCount > 0 {
|
// 注意:无论本地是否有其他客户端,都要发布到 Redis,因为其他服务器可能有客户端
|
||||||
client.idsMu.Lock()
|
client.idsMu.Lock()
|
||||||
clientClocks := make(map[uint64]uint64, len(client.observedYjsIDs))
|
clientClocks := make(map[uint64]uint64, len(client.observedYjsIDs))
|
||||||
for id, clock := range client.observedYjsIDs {
|
for id, clock := range client.observedYjsIDs {
|
||||||
@@ -254,13 +254,14 @@ func (h *Hub) unregisterClient(client *Client) {
|
|||||||
if len(clientClocks) > 0 {
|
if len(clientClocks) > 0 {
|
||||||
// 构造 Yjs 协议格式的删除消息
|
// 构造 Yjs 协议格式的删除消息
|
||||||
deleteMsg := MakeYjsDeleteMessage(clientClocks)
|
deleteMsg := MakeYjsDeleteMessage(clientClocks)
|
||||||
|
|
||||||
|
// 本地广播:只有当本地还有其他客户端时才需要
|
||||||
|
if remainingClientsCount > 0 {
|
||||||
msg := &Message{
|
msg := &Message{
|
||||||
RoomID: client.roomID,
|
RoomID: client.roomID,
|
||||||
Data: deleteMsg,
|
Data: deleteMsg,
|
||||||
sender: nil, // 系统发送
|
sender: nil, // 系统发送
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步发送到广播通道,避免在持有 h.mu 时发生死锁
|
|
||||||
go func() {
|
go func() {
|
||||||
select {
|
select {
|
||||||
case h.Broadcast <- msg:
|
case h.Broadcast <- msg:
|
||||||
@@ -269,29 +270,42 @@ func (h *Hub) unregisterClient(client *Client) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发布到 Redis:无论本地是否有客户端,都要通知其他服务器
|
||||||
|
if !h.fallbackMode && h.messagebus != nil {
|
||||||
|
go func(roomID string, data []byte) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.messagebus.Publish(ctx, roomID, data); err != nil {
|
||||||
|
h.logger.Error("Failed to publish delete message to Redis",
|
||||||
|
zap.String("room_id", roomID),
|
||||||
|
zap.Error(err))
|
||||||
|
} else {
|
||||||
|
h.logger.Debug("Published delete message to Redis",
|
||||||
|
zap.String("room_id", roomID),
|
||||||
|
zap.Int("yjs_ids_count", len(clientClocks)))
|
||||||
|
}
|
||||||
|
}(client.roomID, deleteMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 房间清理:如果是最后一个人,彻底销毁房间资源
|
// 4. 房间清理:如果是本服务器最后一个人,清理本地资源
|
||||||
|
// 注意:不要删除整个 Redis Hash,因为其他服务器可能还有客户端
|
||||||
if remainingClientsCount == 0 {
|
if remainingClientsCount == 0 {
|
||||||
h.logger.Info("Room is empty, performing deep cleanup", zap.String("room_id", client.roomID))
|
h.logger.Info("Room is empty on this server, cleaning up local resources", zap.String("room_id", client.roomID))
|
||||||
|
|
||||||
// A. 停止转发协程
|
// A. 停止转发协程
|
||||||
if room.cancel != nil {
|
if room.cancel != nil {
|
||||||
room.cancel()
|
room.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// B. 分布式彻底清理 (关键改动!)
|
// B. 取消 Redis 订阅(但不删除 awareness hash,其他服务器可能还有客户端)
|
||||||
if !h.fallbackMode && h.messagebus != nil {
|
if !h.fallbackMode && h.messagebus != nil {
|
||||||
go func(rID string) {
|
go func(rID string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// ✨ 1. 直接删除整个 Redis Hash 表,不留任何死角
|
// 只取消订阅,不清除 awareness(已在步骤2中按 Yjs ID 单独删除)
|
||||||
if err := h.messagebus.ClearAllAwareness(ctx, rID); err != nil {
|
|
||||||
h.logger.Warn("Failed to clear total awareness from Redis", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 取消订阅
|
|
||||||
if err := h.messagebus.Unsubscribe(ctx, rID); err != nil {
|
if err := h.messagebus.Unsubscribe(ctx, rID); err != nil {
|
||||||
h.logger.Warn("Failed to unsubscribe", zap.Error(err))
|
h.logger.Warn("Failed to unsubscribe", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ func NewPostgresStore(databaseUrl string) (*PostgresStore, error) {
|
|||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
}
|
}
|
||||||
db.SetMaxOpenConns(25)
|
db.SetMaxOpenConns(200)
|
||||||
db.SetMaxIdleConns(5)
|
db.SetMaxIdleConns(50)
|
||||||
db.SetConnMaxLifetime(5 * time.Minute)
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
return &PostgresStore{db: db}, nil
|
return &PostgresStore{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
56
frontend/@/components/ui/button.tsx
Normal file
56
frontend/@/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
79
frontend/@/components/ui/card.tsx
Normal file
79
frontend/@/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
53
frontend/@/components/ui/tabs.tsx
Normal file
53
frontend/@/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
17
frontend/components.json
Normal file
17
frontend/components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>Realtime Collab</title>
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1570
frontend/package-lock.json
generated
1570
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,14 +13,20 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tiptap/extension-collaboration": "^2.27.1",
|
"@tiptap/extension-collaboration": "^2.27.1",
|
||||||
"@tiptap/extension-collaboration-cursor": "^2.26.2",
|
"@tiptap/extension-collaboration-cursor": "^2.26.2",
|
||||||
"@tiptap/pm": "^2.27.1",
|
"@tiptap/pm": "^2.27.1",
|
||||||
"@tiptap/react": "^2.27.1",
|
"@tiptap/react": "^2.27.1",
|
||||||
"@tiptap/starter-kit": "^2.27.1",
|
"@tiptap/starter-kit": "^2.27.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-websocket": "^3.0.0",
|
"y-websocket": "^3.0.0",
|
||||||
"yjs": "^13.6.28"
|
"yjs": "^13.6.28"
|
||||||
@@ -31,10 +37,14 @@
|
|||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
|
|||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export type DocumentType = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "editor" | "kanban";
|
type: "editor" | "kanban";
|
||||||
|
owner_id?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/components/Home/CreateButton.tsx
Normal file
55
frontend/src/components/Home/CreateButton.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Plus, FileText, KanbanSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CreateButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon: 'plus' | 'document' | 'kanban';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
plus: Plus,
|
||||||
|
document: FileText,
|
||||||
|
kanban: KanbanSquare,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateButton({ onClick, disabled, icon, children }: CreateButtonProps) {
|
||||||
|
const Icon = iconMap[icon];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="
|
||||||
|
bg-pixel-purple-bright
|
||||||
|
hover:bg-pixel-purple-deep
|
||||||
|
text-white
|
||||||
|
border-[3px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel
|
||||||
|
hover:shadow-pixel-hover
|
||||||
|
hover:-translate-y-0.5
|
||||||
|
hover:-translate-x-0.5
|
||||||
|
active:translate-y-0.5
|
||||||
|
active:translate-x-0.5
|
||||||
|
active:shadow-pixel-active
|
||||||
|
transition-all
|
||||||
|
duration-75
|
||||||
|
font-sans
|
||||||
|
font-semibold
|
||||||
|
px-6
|
||||||
|
py-3
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
gap-2
|
||||||
|
disabled:opacity-60
|
||||||
|
disabled:cursor-not-allowed
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/components/Home/DocumentCard.tsx
Normal file
129
frontend/src/components/Home/DocumentCard.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { DocumentType } from '@/api/document';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { FileText, KanbanSquare, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface DocumentCardProps {
|
||||||
|
doc: DocumentType;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
isShared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentCard({ doc, onDelete, isShared }: DocumentCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const Icon = doc.type === 'editor' ? FileText : KanbanSquare;
|
||||||
|
const typeLabel = doc.type === 'editor' ? 'Text Document' : 'Kanban Board';
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
navigate(`/${doc.type}/${doc.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="
|
||||||
|
group
|
||||||
|
relative
|
||||||
|
bg-pixel-white
|
||||||
|
border-[3px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel-md
|
||||||
|
hover:shadow-pixel-lg
|
||||||
|
hover:-translate-y-0.5
|
||||||
|
hover:-translate-x-0.5
|
||||||
|
transition-all
|
||||||
|
duration-100
|
||||||
|
p-6
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
gap-4
|
||||||
|
">
|
||||||
|
{/* Header with icon and type */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="
|
||||||
|
bg-pixel-cyan-bright
|
||||||
|
p-2
|
||||||
|
border-[2px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel-sm
|
||||||
|
">
|
||||||
|
<Icon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-pixel text-sm text-pixel-text-primary mb-1">
|
||||||
|
{doc.name}
|
||||||
|
</h3>
|
||||||
|
<span className="font-sans text-xs text-pixel-text-muted">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isShared && (
|
||||||
|
<span className="
|
||||||
|
bg-pixel-pink-vibrant
|
||||||
|
text-white
|
||||||
|
font-sans
|
||||||
|
text-xs
|
||||||
|
font-semibold
|
||||||
|
px-2
|
||||||
|
py-1
|
||||||
|
border-[2px]
|
||||||
|
border-pixel-outline
|
||||||
|
">
|
||||||
|
Shared
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="font-sans text-xs text-pixel-text-muted">
|
||||||
|
Created {new Date(doc.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 mt-auto">
|
||||||
|
<Button
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="
|
||||||
|
flex-1
|
||||||
|
bg-pixel-cyan-bright
|
||||||
|
hover:bg-pixel-purple-bright
|
||||||
|
text-white
|
||||||
|
border-[2px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel-sm
|
||||||
|
hover:shadow-pixel-hover
|
||||||
|
hover:-translate-y-0.5
|
||||||
|
hover:-translate-x-0.5
|
||||||
|
transition-all
|
||||||
|
duration-75
|
||||||
|
font-sans
|
||||||
|
font-semibold
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
{!isShared && (
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(doc.id)}
|
||||||
|
variant="outline"
|
||||||
|
className="
|
||||||
|
border-[2px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel-sm
|
||||||
|
hover:shadow-pixel-hover
|
||||||
|
hover:-translate-y-0.5
|
||||||
|
hover:-translate-x-0.5
|
||||||
|
hover:bg-red-50
|
||||||
|
transition-all
|
||||||
|
duration-75
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import './Navbar.css';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { LogOut } from 'lucide-react';
|
||||||
|
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -7,24 +8,68 @@ function Navbar() {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar">
|
<nav className="
|
||||||
<div className="navbar-content">
|
bg-pixel-white
|
||||||
<div className="navbar-brand">
|
border-b-[3px]
|
||||||
<a href="/">Realtime Collab</a>
|
border-pixel-outline
|
||||||
</div>
|
shadow-pixel-sm
|
||||||
|
sticky
|
||||||
|
top-0
|
||||||
|
z-50
|
||||||
|
">
|
||||||
|
<div className="max-w-7xl mx-auto px-8 py-4 flex items-center justify-between">
|
||||||
|
{/* Brand */}
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="
|
||||||
|
font-pixel
|
||||||
|
text-xl
|
||||||
|
text-pixel-purple-bright
|
||||||
|
hover:text-pixel-cyan-bright
|
||||||
|
transition-colors
|
||||||
|
duration-200
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Realtime Collab
|
||||||
|
</a>
|
||||||
|
|
||||||
<div className="navbar-user">
|
{/* User Section */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
{user.avatar_url && (
|
{user.avatar_url && (
|
||||||
<img
|
<img
|
||||||
src={user.avatar_url}
|
src={user.avatar_url}
|
||||||
alt={user.name}
|
alt={user.name}
|
||||||
className="user-avatar"
|
className="
|
||||||
|
w-10
|
||||||
|
h-10
|
||||||
|
border-[3px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel-sm
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="user-name">{user.name}</span>
|
<span className="font-sans font-medium text-pixel-text-primary hidden sm:inline">
|
||||||
<button onClick={logout} className="logout-button">
|
{user.name}
|
||||||
Logout
|
</span>
|
||||||
</button>
|
<Button
|
||||||
|
onClick={logout}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="
|
||||||
|
border-[3px]
|
||||||
|
border-pixel-outline
|
||||||
|
shadow-pixel-sm
|
||||||
|
hover:shadow-pixel-hover
|
||||||
|
hover:-translate-y-0.5
|
||||||
|
hover:-translate-x-0.5
|
||||||
|
transition-all
|
||||||
|
duration-75
|
||||||
|
font-sans
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">Logout</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
53
frontend/src/components/ui/button.tsx
Normal file
53
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
78
frontend/src/components/ui/card.tsx
Normal file
78
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
52
frontend/src/components/ui/tabs.tsx
Normal file
52
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -1,3 +1,32 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 232 217 243;
|
||||||
|
--foreground: 43 27 56;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 43 27 56;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 43 27 56;
|
||||||
|
--primary: 277 42% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 190 100% 50%;
|
||||||
|
--secondary-foreground: 43 27 56;
|
||||||
|
--muted: 276 100% 97%;
|
||||||
|
--muted-foreground: 240 13% 40%;
|
||||||
|
--accent: 325 100% 71%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 43 27 56;
|
||||||
|
--input: 43 27 56;
|
||||||
|
--ring: 190 100% 50%;
|
||||||
|
--radius: 0rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { DocumentType } from "../api/document.ts";
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { documentsApi } from "../api/document.ts";
|
import type { DocumentType } from '@/api/document';
|
||||||
import Navbar from "../components/Navbar.tsx";
|
import { documentsApi } from '@/api/document';
|
||||||
import PixelIcon from "../components/PixelIcon/PixelIcon.tsx";
|
import Navbar from '@/components/Navbar';
|
||||||
import FloatingGem from "../components/PixelSprites/FloatingGem.tsx";
|
import { DocumentCard } from '@/components/Home/DocumentCard';
|
||||||
|
import { CreateButton } from '@/components/Home/CreateButton';
|
||||||
|
import FloatingGem from '@/components/PixelSprites/FloatingGem';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
const [documents, setDocuments] = useState<DocumentType[]>([]);
|
const [documents, setDocuments] = useState<DocumentType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -17,8 +21,8 @@ const Home = () => {
|
|||||||
const { documents } = await documentsApi.list();
|
const { documents } = await documentsApi.list();
|
||||||
setDocuments(documents || []);
|
setDocuments(documents || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load documents:", error);
|
console.error('Failed to load documents:', error);
|
||||||
setDocuments([]); // Set empty array on error to prevent null access
|
setDocuments([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -28,87 +32,160 @@ const Home = () => {
|
|||||||
loadDocuments();
|
loadDocuments();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createDocument = async (type: "editor" | "kanban") => {
|
// Filter documents into owned and shared
|
||||||
|
const ownedDocuments = documents.filter(doc => doc.owner_id === user?.id);
|
||||||
|
const sharedDocuments = documents.filter(doc => doc.owner_id !== user?.id);
|
||||||
|
|
||||||
|
const createDocument = async (type: 'editor' | 'kanban') => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const doc = await documentsApi.create({
|
const doc = await documentsApi.create({
|
||||||
name: `New ${type === "editor" ? "Document" : "Kanban Board"}`,
|
name: `New ${type === 'editor' ? 'Document' : 'Kanban Board'}`,
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
navigate(`/${type}/${doc.id}`);
|
navigate(`/${type}/${doc.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create document:", error);
|
console.error('Failed to create document:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteDocument = async (id: string) => {
|
const deleteDocument = async (id: string) => {
|
||||||
if (!confirm("Are you sure you want to delete this document?")) return;
|
if (!confirm('Are you sure you want to delete this document?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await documentsApi.delete(id);
|
await documentsApi.delete(id);
|
||||||
loadDocuments();
|
loadDocuments();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete document:", error);
|
console.error('Failed to delete document:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Loading documents...</div>;
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-pixel-bg-light">
|
||||||
|
<div className="font-pixel text-pixel-purple-bright animate-pixel-bounce">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="home-page" style={{ position: 'relative' }}>
|
<div className="max-w-7xl mx-auto px-8 py-12 relative min-h-screen bg-pixel-bg-light">
|
||||||
|
{/* Decorative floating gems */}
|
||||||
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
|
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
|
||||||
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
|
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
|
||||||
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
|
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
|
||||||
|
|
||||||
<h1>My Documents</h1>
|
{/* Page Header */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="font-pixel text-4xl text-pixel-purple-bright mb-6 tracking-wide">
|
||||||
|
My Workspace
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="create-buttons">
|
{/* Create Buttons */}
|
||||||
<button onClick={() => createDocument("editor")} disabled={creating}>
|
<div className="flex gap-4 mb-8 flex-wrap">
|
||||||
<PixelIcon name="plus" size={20} />
|
<CreateButton
|
||||||
<span style={{ marginLeft: '8px' }}>New Text Document</span>
|
onClick={() => createDocument('editor')}
|
||||||
</button>
|
disabled={creating}
|
||||||
<button onClick={() => createDocument("kanban")} disabled={creating}>
|
icon="plus"
|
||||||
<PixelIcon name="plus" size={20} />
|
>
|
||||||
<span style={{ marginLeft: '8px' }}>New Kanban Board</span>
|
New Text Document
|
||||||
</button>
|
</CreateButton>
|
||||||
|
<CreateButton
|
||||||
|
onClick={() => createDocument('kanban')}
|
||||||
|
disabled={creating}
|
||||||
|
icon="plus"
|
||||||
|
>
|
||||||
|
New Kanban Board
|
||||||
|
</CreateButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="document-list">
|
{/* Tabbed Interface */}
|
||||||
{documents.length === 0 ? (
|
<Tabs defaultValue="owned" className="w-full">
|
||||||
<p>No documents yet. Create one to get started!</p>
|
<TabsList className="
|
||||||
) : (
|
bg-pixel-panel
|
||||||
documents.map((doc) => (
|
border-[3px]
|
||||||
<div key={doc.id} className="document-card">
|
border-pixel-outline
|
||||||
<div className="doc-info">
|
shadow-pixel-sm
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
p-1
|
||||||
<PixelIcon name={doc.type === 'editor' ? 'document' : 'kanban'} size={24} color="var(--pixel-purple-bright)" />
|
mb-8
|
||||||
<h3 style={{ margin: 0 }}>{doc.name}</h3>
|
">
|
||||||
</div>
|
<TabsTrigger
|
||||||
<p className="doc-type">{doc.type}</p>
|
value="owned"
|
||||||
<p className="doc-date">
|
className="
|
||||||
Created: {new Date(doc.created_at).toLocaleDateString()}
|
font-sans
|
||||||
|
font-semibold
|
||||||
|
data-[state=active]:bg-pixel-cyan-bright
|
||||||
|
data-[state=active]:text-white
|
||||||
|
data-[state=active]:shadow-pixel-sm
|
||||||
|
transition-all
|
||||||
|
duration-100
|
||||||
|
"
|
||||||
|
>
|
||||||
|
My Documents ({ownedDocuments.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="shared"
|
||||||
|
className="
|
||||||
|
font-sans
|
||||||
|
font-semibold
|
||||||
|
data-[state=active]:bg-pixel-cyan-bright
|
||||||
|
data-[state=active]:text-white
|
||||||
|
data-[state=active]:shadow-pixel-sm
|
||||||
|
transition-all
|
||||||
|
duration-100
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Shared with Me ({sharedDocuments.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Owned Documents Tab */}
|
||||||
|
<TabsContent value="owned">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{ownedDocuments.length === 0 ? (
|
||||||
|
<p className="col-span-full text-center text-pixel-text-muted font-sans py-12">
|
||||||
|
No documents yet. Create one to get started!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
<div className="doc-actions">
|
ownedDocuments.map((doc) => (
|
||||||
<button onClick={() => navigate(`/${doc.type}/${doc.id}`)} aria-label={`Open ${doc.name}`}>
|
<DocumentCard
|
||||||
<PixelIcon name="back-arrow" size={16} style={{ transform: 'rotate(180deg)' }} />
|
key={doc.id}
|
||||||
<span style={{ marginLeft: '6px' }}>Open</span>
|
doc={doc}
|
||||||
</button>
|
onDelete={deleteDocument}
|
||||||
<button onClick={() => deleteDocument(doc.id)} aria-label={`Delete ${doc.name}`}>
|
isShared={false}
|
||||||
<PixelIcon name="trash" size={16} />
|
/>
|
||||||
<span style={{ marginLeft: '6px' }}>Delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Shared Documents Tab */}
|
||||||
|
<TabsContent value="shared">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{sharedDocuments.length === 0 ? (
|
||||||
|
<p className="col-span-full text-center text-pixel-text-muted font-sans py-12">
|
||||||
|
No shared documents yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
sharedDocuments.map((doc) => (
|
||||||
|
<DocumentCard
|
||||||
|
key={doc.id}
|
||||||
|
doc={doc}
|
||||||
|
onDelete={deleteDocument}
|
||||||
|
isShared={true}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
72
frontend/tailwind.config.js
Normal file
72
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// shadcn system colors
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: { DEFAULT: '#8B4FB9', foreground: '#FFFFFF' },
|
||||||
|
secondary: { DEFAULT: '#00D9FF', foreground: '#2B1B38' },
|
||||||
|
accent: { DEFAULT: '#FF6EC7', foreground: '#FFFFFF' },
|
||||||
|
muted: { DEFAULT: '#F5F0FF', foreground: '#4A3B5C' },
|
||||||
|
|
||||||
|
// Custom pixel palette
|
||||||
|
pixel: {
|
||||||
|
purple: { deep: '#4A1B6F', bright: '#8B4FB9' },
|
||||||
|
cyan: { bright: '#00D9FF' },
|
||||||
|
pink: { vibrant: '#FF6EC7' },
|
||||||
|
orange: { warm: '#FF8E3C' },
|
||||||
|
yellow: { gold: '#FFD23F' },
|
||||||
|
green: { lime: '#8EF048', forest: '#3FA54D' },
|
||||||
|
bg: { dark: '#2B1B38', medium: '#4A3B5C', light: '#E8D9F3' },
|
||||||
|
panel: '#F5F0FF',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
shadow: { dark: '#1A0E28' },
|
||||||
|
outline: '#2B1B38',
|
||||||
|
text: { primary: '#2B1B38', secondary: '#4A3B5C', muted: '#8B7B9C' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
pixel: ['"Press Start 2P"', 'cursive'],
|
||||||
|
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'pixel-sm': '3px 3px 0 #1A0E28',
|
||||||
|
'pixel': '4px 4px 0 #1A0E28, 4px 4px 0 3px #2B1B38',
|
||||||
|
'pixel-md': '6px 6px 0 #1A0E28, 6px 6px 0 3px #2B1B38',
|
||||||
|
'pixel-lg': '8px 8px 0 #1A0E28, 8px 8px 0 3px #2B1B38',
|
||||||
|
'pixel-hover': '5px 5px 0 #1A0E28',
|
||||||
|
'pixel-active': '2px 2px 0 #1A0E28',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'pixel-float': {
|
||||||
|
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
|
||||||
|
'25%': { transform: 'translateY(-12px) rotate(2deg)' },
|
||||||
|
'50%': { transform: 'translateY(-8px) rotate(0deg)' },
|
||||||
|
'75%': { transform: 'translateY(-12px) rotate(-2deg)' },
|
||||||
|
},
|
||||||
|
'pixel-bounce': {
|
||||||
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-16px)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pixel-float': 'pixel-float 6s ease-in-out infinite',
|
||||||
|
'pixel-bounce': 'pixel-bounce 1s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
DEFAULT: '0',
|
||||||
|
lg: '0',
|
||||||
|
md: '0',
|
||||||
|
sm: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
}
|
||||||
@@ -22,7 +22,13 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* Path Mapping */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,11 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user