feat(phase4): rich profiles, badges DB, block list
This commit is contained in:
@@ -44,6 +44,18 @@
|
|||||||
| 3.1 | Threads | ✅ Reused channels table + thread panel |
|
| 3.1 | Threads | ✅ Reused channels table + thread panel |
|
||||||
| 3.2 | Forum Channels | ✅ Forum posts as threads + tags + card view |
|
| 3.2 | Forum Channels | ✅ Forum posts as threads + tags + card view |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — User System Enhancements ✅ COMPLETE
|
||||||
|
|
||||||
|
> Rich profiles and social features that make Guilded feel polished.
|
||||||
|
|
||||||
|
| # | Feature | Status |
|
||||||
|
|---|---------|--------|
|
||||||
|
| 4.1 | Rich User Profiles | ✅ Profile modal + block button |
|
||||||
|
| 4.2 | Badges | ✅ DB + backend read; no grant UI |
|
||||||
|
| 4.3 | Block List | ✅ Backend + settings stub |
|
||||||
|
|
||||||
### 2.1 Per-Channel Permission Overrides
|
### 2.1 Per-Channel Permission Overrides
|
||||||
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
|
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
|
||||||
- **Route:** `PUT /channels/{channelID}/permissions/{targetType}/{targetID}`, `GET /channels/{channelID}/permissions`, `DELETE /channels/{channelID}/permissions/{targetType}/{targetID}`.
|
- **Route:** `PUT /channels/{channelID}/permissions/{targetType}/{targetID}`, `GET /channels/{channelID}/permissions`, `DELETE /channels/{channelID}/permissions/{targetType}/{targetID}`.
|
||||||
|
|||||||
+194
-4
@@ -42,6 +42,156 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
|||||||
r.Get("/auth/me", h.Me)
|
r.Get("/auth/me", h.Me)
|
||||||
r.Patch("/auth/me", h.UpdateProfile)
|
r.Patch("/auth/me", h.UpdateProfile)
|
||||||
r.Put("/auth/me/password", h.ChangePassword)
|
r.Put("/auth/me/password", h.ChangePassword)
|
||||||
|
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
||||||
|
r.Get("/users/me/blocks", h.ListBlocks)
|
||||||
|
r.Post("/users/me/blocks", h.BlockUser)
|
||||||
|
r.Delete("/users/me/blocks/{userID}", h.UnblockUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get public user profile
|
||||||
|
// @Description Get public profile for any user
|
||||||
|
// @Tags users
|
||||||
|
// @Produce json
|
||||||
|
// @Param userID path string true "User ID"
|
||||||
|
// @Success 200 {object} PublicUserProfile
|
||||||
|
// @Router /users/{userID}/profile [get]
|
||||||
|
func (h *Handler) GetPublicProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := chi.URLParam(r, "userID")
|
||||||
|
profile, err := h.getPublicProfile(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getPublicProfile(ctx context.Context, userID string) (PublicUserProfile, error) {
|
||||||
|
var p PublicUserProfile
|
||||||
|
var socialLinks []byte
|
||||||
|
err := h.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at,
|
||||||
|
banner_url, tagline, pronouns, social_links
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, userID).Scan(&p.ID, &p.Username, &p.DisplayName, &p.Email, &p.Avatar, &p.Bio, &p.AccentColor, &p.Status, &p.StatusText, &p.CreatedAt, &p.BannerURL, &p.Tagline, &p.Pronouns, &socialLinks)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
if len(socialLinks) > 0 {
|
||||||
|
_ = json.Unmarshal(socialLinks, &p.SocialLinks)
|
||||||
|
}
|
||||||
|
p.Badges = h.getUserBadges(ctx, userID)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getUserBadges(ctx context.Context, userID string) []badgeResponse {
|
||||||
|
rows, err := h.db.QueryContext(ctx, `
|
||||||
|
SELECT b.id, b.name, b.icon, b.description, b.server_id
|
||||||
|
FROM badges b
|
||||||
|
JOIN user_badges ub ON ub.badge_id = b.id
|
||||||
|
WHERE ub.user_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
badges := make([]badgeResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var b badgeResponse
|
||||||
|
var serverID sql.NullString
|
||||||
|
if err := rows.Scan(&b.ID, &b.Name, &b.Icon, &b.Description, &serverID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if serverID.Valid {
|
||||||
|
b.ServerID = serverID.String
|
||||||
|
}
|
||||||
|
badges = append(badges, b)
|
||||||
|
}
|
||||||
|
return badges
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary List blocked users
|
||||||
|
// @Description List users the current user has blocked
|
||||||
|
// @Tags users
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} blockResponse
|
||||||
|
// @Router /users/me/blocks [get]
|
||||||
|
func (h *Handler) ListBlocks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT u.id, u.username, b.created_at::text
|
||||||
|
FROM blocks b
|
||||||
|
JOIN users u ON u.id = b.blocked_id
|
||||||
|
WHERE b.blocker_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
blocks := make([]blockResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var b blockResponse
|
||||||
|
if err := rows.Scan(&b.ID, &b.Username, &b.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blocks = append(blocks, b)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Block a user
|
||||||
|
// @Description Block a user by ID
|
||||||
|
// @Tags users
|
||||||
|
// @Accept json
|
||||||
|
// @Success 204
|
||||||
|
// @Router /users/me/blocks [post]
|
||||||
|
func (h *Handler) BlockUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO blocks (blocker_id, blocked_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||||
|
`, userID, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to block user"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Unblock a user
|
||||||
|
// @Description Unblock a user by ID
|
||||||
|
// @Tags users
|
||||||
|
// @Success 204
|
||||||
|
// @Router /users/me/blocks/{userID} [delete]
|
||||||
|
func (h *Handler) UnblockUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID := chi.URLParam(r, "userID")
|
||||||
|
_, err := h.db.ExecContext(r.Context(), `DELETE FROM blocks WHERE blocker_id = $1 AND blocked_id = $2`, userID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to unblock user"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
type registerRequest struct {
|
type registerRequest struct {
|
||||||
@@ -63,6 +213,36 @@ type updateProfileRequest struct {
|
|||||||
AccentColor string `json:"accent_color"`
|
AccentColor string `json:"accent_color"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText string `json:"status_text"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
BannerURL string `json:"banner_url"`
|
||||||
|
Tagline string `json:"tagline"`
|
||||||
|
Pronouns string `json:"pronouns"`
|
||||||
|
SocialLinks []struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"social_links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicUserProfile struct {
|
||||||
|
UserProfile
|
||||||
|
BannerURL string `json:"banner_url"`
|
||||||
|
Tagline string `json:"tagline"`
|
||||||
|
Pronouns string `json:"pronouns"`
|
||||||
|
SocialLinks []map[string]interface{} `json:"social_links"`
|
||||||
|
Badges []badgeResponse `json:"badges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type badgeResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ServerID string `json:"server_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type changePasswordRequest struct {
|
type changePasswordRequest struct {
|
||||||
@@ -279,6 +459,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(req.Tagline) > 128 {
|
||||||
|
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Pronouns) > 40 {
|
||||||
|
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
||||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -288,11 +476,13 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
||||||
_, err := h.db.ExecContext(r.Context(), `
|
_, err := h.db.ExecContext(r.Context(), `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status)
|
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
||||||
|
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status)
|
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -368,8 +558,8 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err = VerifyPassword(req.CurrentPassword, currentHash)
|
verified, _ := VerifyPassword(req.CurrentPassword, currentHash)
|
||||||
if err != nil || !ok {
|
if !verified {
|
||||||
http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,42 @@ ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEF
|
|||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_url TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tagline VARCHAR(128);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_links JSONB DEFAULT '[]';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS pronouns VARCHAR(40);
|
||||||
|
|
||||||
|
-- Badges
|
||||||
|
CREATE TABLE IF NOT EXISTS badges (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
server_id UUID REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_badges_server ON badges(server_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_badges (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
badge_id UUID NOT NULL REFERENCES badges(id) ON DELETE CASCADE,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
PRIMARY KEY (user_id, badge_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Block list
|
||||||
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
|
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (blocker_id, blocked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON blocks(blocked_id);
|
||||||
|
|
||||||
-- Message full-text search
|
-- Message full-text search
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MessageSearch } from "./MessageSearch";
|
|||||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||||
import { ThreadPanel } from "./ThreadPanel.tsx";
|
import { ThreadPanel } from "./ThreadPanel.tsx";
|
||||||
import { ForumView } from "./ForumView.tsx";
|
import { ForumView } from "./ForumView.tsx";
|
||||||
|
import { UserProfileModal } from "./UserProfileModal.tsx";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ export function ChatArea() {
|
|||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [showThreads, setShowThreads] = useState(false);
|
const [showThreads, setShowThreads] = useState(false);
|
||||||
const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
|
const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [profileUserId, setProfileUserId] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -284,7 +286,7 @@ export function ChatArea() {
|
|||||||
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||||
<ThreadListPanel
|
<ThreadListPanel
|
||||||
channelId={activeChannelId}
|
channelId={activeChannelId}
|
||||||
onSelect={(t) => setActiveThread(t)}
|
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
|
||||||
onClose={() => setShowThreads(false)}
|
onClose={() => setShowThreads(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -339,7 +341,9 @@ export function ChatArea() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
|
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
|
||||||
<span className="text-gb-aqua"><{message.author_username}></span>{" "}
|
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={() => setProfileUserId(message.author_id)}>
|
||||||
|
<{message.author_username}>
|
||||||
|
</span>{" "}
|
||||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||||
{renderEmbeds(message.embeds)}
|
{renderEmbeds(message.embeds)}
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +398,9 @@ export function ChatArea() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{profileUserId && (
|
||||||
|
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuthStore, type PublicProfile } from '../stores/auth.ts';
|
||||||
|
|
||||||
|
interface UserProfileModalProps {
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
|
||||||
|
const getPublicProfile = useAuthStore((s) => s.getPublicProfile);
|
||||||
|
const blockUser = useAuthStore((s) => s.blockUser);
|
||||||
|
const [profile, setProfile] = useState<PublicProfile | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicProfile(userId).then(setProfile).catch(setError);
|
||||||
|
}, [userId, getPublicProfile]);
|
||||||
|
|
||||||
|
const statusColor = {
|
||||||
|
online: 'bg-gb-green',
|
||||||
|
idle: 'bg-gb-orange',
|
||||||
|
dnd: 'bg-gb-red',
|
||||||
|
offline: 'bg-gb-fg-t',
|
||||||
|
}[profile?.status || 'offline'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={onClose}>
|
||||||
|
<div className="bg-gb-bg border border-gb-fg-t w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{error && <div className="p-3 text-gb-red text-xs">ERR: {error}</div>}
|
||||||
|
{!profile && !error && <div className="p-6 text-gb-fg-f text-center text-xs">[loading...]</div>}
|
||||||
|
{profile && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="h-24 bg-cover bg-center border-b border-gb-bg-t"
|
||||||
|
style={{ backgroundImage: profile.banner_url ? `url(${profile.banner_url})` : undefined, backgroundColor: profile.accent_color || '#2D3436' }}
|
||||||
|
/>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start gap-3 -mt-12 mb-3">
|
||||||
|
<img src={profile.avatar} alt="" className="w-20 h-20 border-2 border-gb-bg bg-gb-bg object-cover" />
|
||||||
|
<div className="mt-12">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${statusColor}`} />
|
||||||
|
<span className="text-gb-fg font-bold">{profile.display_name || profile.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gb-fg-s text-xs">@{profile.username}</div>
|
||||||
|
{profile.pronouns && <div className="text-gb-fg-t text-[10px]">{profile.pronouns}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profile.tagline && <div className="text-gb-fg text-sm mb-2 italic">"{profile.tagline}"</div>}
|
||||||
|
{profile.bio && <div className="text-gb-fg-s text-xs mb-3 whitespace-pre-wrap">{profile.bio}</div>}
|
||||||
|
{profile.social_links && profile.social_links.length > 0 && (
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
{profile.social_links.map((link, idx) => (
|
||||||
|
<a key={idx} href={link.url} target="_blank" rel="noreferrer" className="text-gb-aqua text-xs block hover:underline">
|
||||||
|
{link.platform}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.badges && profile.badges.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{profile.badges.map((badge) => (
|
||||||
|
<span key={badge.id} className="text-[10px] px-1.5 py-0.5 border border-gb-bg-t text-gb-fg-s" title={badge.description}>
|
||||||
|
{badge.icon && <span className="mr-1">{badge.icon}</span>}
|
||||||
|
{badge.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[CLOSE]</button>
|
||||||
|
<button
|
||||||
|
onClick={() => blockUser(userId).then(onClose)}
|
||||||
|
className="text-xs text-gb-red hover:text-gb-orange font-mono"
|
||||||
|
>
|
||||||
|
[BLOCK]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,14 +14,32 @@ export interface User {
|
|||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
status_text: string;
|
status_text: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
banner_url?: string;
|
||||||
|
tagline?: string;
|
||||||
|
pronouns?: string;
|
||||||
|
social_links?: { platform: string; url: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicProfile extends User {
|
||||||
|
badges: { id: string; name: string; icon: string; description?: string; server_id?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlockedUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UpdateProfilePayload {
|
export interface UpdateProfilePayload {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
accent_color?: string;
|
accent_color?: string;
|
||||||
status_text?: string;
|
status_text?: string;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
|
banner_url?: string;
|
||||||
|
tagline?: string;
|
||||||
|
pronouns?: string;
|
||||||
|
social_links?: { platform: string; url: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -43,6 +61,10 @@ interface AuthState {
|
|||||||
currentPassword: string,
|
currentPassword: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
getPublicProfile: (userId: string) => Promise<PublicProfile>;
|
||||||
|
listBlocks: () => Promise<BlockedUser[]>;
|
||||||
|
blockUser: (userId: string) => Promise<void>;
|
||||||
|
unblockUser: (userId: string) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,4 +178,16 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
getPublicProfile: async (userId) => api.get<PublicProfile>(`/users/${userId}/profile`),
|
||||||
|
|
||||||
|
listBlocks: async () => api.get<BlockedUser[]>('/users/me/blocks'),
|
||||||
|
|
||||||
|
blockUser: async (userId) => {
|
||||||
|
await api.post('/users/me/blocks', { user_id: userId });
|
||||||
|
},
|
||||||
|
|
||||||
|
unblockUser: async (userId) => {
|
||||||
|
await api.delete(`/users/me/blocks/${userId}`);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user