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:
M1ngdaXie
2026-02-05 15:06:34 -08:00
parent c84cbafb2c
commit 6fac2f7997
25 changed files with 2547 additions and 121 deletions

View File

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

View File

@@ -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,
@@ -89,7 +89,7 @@ func (h *Hub) registerClient(client *Client) {
defer h.mu.Unlock() defer h.mu.Unlock()
room, exists := h.rooms[client.roomID] room, exists := h.rooms[client.roomID]
// --- 1. 初始化房间 (仅针对该服务器上的第一个人) --- // --- 1. 初始化房间 (仅针对该服务器上的第一个人) ---
if !exists { if !exists {
room = &Room{ room = &Room{
@@ -242,25 +242,26 @@ 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 {
clientClocks[id] = clock clientClocks[id] = clock
} }
client.idsMu.Unlock() client.idsMu.Unlock()
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,37 +270,50 @@ 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. 房间清理:如果是本服务器最后一个人,清理本地资源
if remainingClientsCount == 0 { // 注意:不要删除整个 Redis Hash因为其他服务器可能还有客户端
h.logger.Info("Room is empty, performing deep cleanup", zap.String("room_id", client.roomID)) if remainingClientsCount == 0 {
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 表,不留任何死角
if err := h.messagebus.ClearAllAwareness(ctx, rID); err != nil {
h.logger.Warn("Failed to clear total awareness from Redis", zap.Error(err))
}
// 2. 取消订阅 // 取消订阅,不清除 awareness已在步骤2中按 Yjs ID 单独删除)
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))
} }
}(client.roomID) }(client.roomID)
} }
// C. 从内存中移除 // C. 从内存中移除
delete(h.rooms, client.roomID) delete(h.rooms, client.roomID)
} }
h.mu.Unlock() // 手动释放 Hub 锁 h.mu.Unlock() // 手动释放 Hub 锁
} }

View File

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

View 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 }

View 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 }

View 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
View 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"
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

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

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

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

View File

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

View 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 }

View 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 }

View 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 }

View File

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

View 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))
}

View File

@@ -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>
</div> <CreateButton
onClick={() => createDocument('kanban')}
disabled={creating}
icon="plus"
>
New Kanban Board
</CreateButton>
</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>
)) </TabsContent>
)}
</div> {/* 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>
</> </>
); );

View 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')],
}

View File

@@ -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"]
} }

View File

@@ -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/*"]
}
}
} }

View File

@@ -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'),
},
},
}) })